diff options
Diffstat (limited to 'src/generic-components/graph/ForceDirectedGraph.jsx')
-rw-r--r-- | src/generic-components/graph/ForceDirectedGraph.jsx | 498 |
1 files changed, 498 insertions, 0 deletions
diff --git a/src/generic-components/graph/ForceDirectedGraph.jsx b/src/generic-components/graph/ForceDirectedGraph.jsx new file mode 100644 index 0000000..caad1c0 --- /dev/null +++ b/src/generic-components/graph/ForceDirectedGraph.jsx @@ -0,0 +1,498 @@ +/* + * ============LICENSE_START=================================================== + * SPARKY (AAI UI service) + * ============================================================================ + * Copyright © 2017 AT&T Intellectual Property. + * Copyright © 2017 Amdocs + * All rights reserved. + * ============================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END===================================================== + * + * ECOMP and OpenECOMP are trademarks + * and service marks of AT&T Intellectual Property. + */ + +import {drag} from 'd3-drag'; +import {forceSimulation, forceLink, forceManyBody, forceCenter} from 'd3-force'; +import {interpolateNumber} from 'd3-interpolate'; +import {select, event as currentEvent} from 'd3-selection'; +import React, {Component, PropTypes} from 'react'; +import {interval, now} from 'd3-timer'; +import {zoom, zoomIdentity} from 'd3-zoom'; +import NodeConstants from './NodeVisualElementConstants.js'; + +import {simulationKeys} from './ForceDefinitions.js'; +import NodeFactory from './NodeFactory.js'; +import NodeVisualElementFactory from './NodeVisualElementFactory.js'; + +class ForceDirectedGraph extends Component { + static propTypes = { + viewWidth: PropTypes.number, + viewHeight: PropTypes.number, + graphData: PropTypes.object, + nodeIdKey: PropTypes.string, + linkIdKey: PropTypes.string, + nodeSelectedCallback: PropTypes.func, + nodeButtonSelectedCallback: PropTypes.func, + currentlySelectedNodeView: PropTypes.string + }; + + static defaultProps = { + viewWidth: 0, + viewHeight: 0, + graphData: { + graphCounter: -1, nodeDataArray: [], linkDataArray: [], graphMeta: {} + }, + nodeIdKey: '', + linkIdKey: '', + nodeSelectedCallback: undefined, + nodeButtonSelectedCallback: undefined, + currentlySelectedNodeView: '' + }; + + constructor(props) { + super(props); + + this.state = { + nodes: [], links: [], mainGroupTransform: zoomIdentity + }; + + this.updateSimulationForce = this.updateSimulationForce.bind(this); + this.resetTransform = this.resetTransform.bind(this); + this.applyBufferDataToState = this.applyBufferDataToState.bind(this); + this.createNodePropForState = this.createNodePropForState.bind(this); + this.createLinkPropForState = this.createLinkPropForState.bind(this); + this.startSimulation = this.startSimulation.bind(this); + this.simulationComplete = this.simulationComplete.bind(this); + this.simulationTick = this.simulationTick.bind(this); + this.nodeSelected = this.nodeSelected.bind(this); + this.onZoom = this.onZoom.bind(this); + this.onGraphDrag = this.onGraphDrag.bind(this); + this.onNodeDrag = this.onNodeDrag.bind(this); + this.addNodeInterpolator = this.addNodeInterpolator.bind(this); + this.runInterpolators = this.runInterpolators.bind(this); + + this.nodeBuffer = []; + this.linkBuffer = []; + this.nodeDatum = []; + this.nodeButtonDatum = []; + this.nodeFactory = new NodeFactory(); + this.visualElementFactory = new NodeVisualElementFactory(); + + this.isGraphMounted = false; + + this.listenerGraphCounter = -1; + this.nodeIndexTracker = new Map(); + this.interpolators = new Map(); + this.areInterpolationsRunning = false; + + this.newNodeSelected = true; + this.currentlySelectedNodeButton = undefined; + + this.intervalTimer = interval(this.applyBufferDataToState, simulationKeys.DATA_COPY_INTERVAL); + this.intervalTimer.stop(); + + this.interpolationTimer = interval(this.runInterpolators, simulationKeys.DATA_COPY_INTERVAL); + this.interpolationTimer.stop(); + + this.simulation = forceSimulation(); + this.simulation.on('end', this.simulationComplete); + this.simulation.stop(); + + this.svgZoom = + zoom().scaleExtent([NodeConstants.SCALE_EXTENT_MIN, NodeConstants.SACEL_EXTENT_MAX]); + this.svgZoom.clickDistance(2); + this.nodeDrag = drag().clickDistance(2); + + this.updateSimulationForce(); + + if (props.graphData) { + if (props.graphData.graphCounter !== -1) { + this.startSimulation(props.graphData); + } + } + } + + componentDidMount() { + this.isGraphMounted = true; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.graphData.graphCounter !== this.props.graphData.graphCounter) { + this.listenerGraphCounter = this.props.graphData.graphCounter; + this.newNodeSelected = true; + this.resetTransform(); + this.startSimulation(nextProps.graphData); + } + } + + componentDidUpdate(prevProps) { + let hasNewGraphDataRendered = (prevProps.graphData.graphCounter === + this.props.graphData.graphCounter); + let shouldAttachListeners = (this.listenerGraphCounter !== this.props.graphData.graphCounter); + let nodeCount = this.state.nodes.length; + + if (nodeCount > 0) { + if (hasNewGraphDataRendered && shouldAttachListeners) { + + let nodes = select('.fdgMainSvg').select('.fdgMainG') + .selectAll('.aai-entity-node') + .data(this.nodeDatum); + + nodes.on('click', (d) => { + this.nodeSelected(d); + }); + + nodes.call(this.nodeDrag.on('drag', (d) => { + let xAndY = [currentEvent.x, currentEvent.y]; + this.onNodeDrag(d, xAndY); + })); + + let mainSVG = select('.fdgMainSvg'); + let mainView = mainSVG.select('.fdgMainView'); + this.svgZoom.transform(mainSVG, zoomIdentity); + this.svgZoom.transform(mainView, zoomIdentity); + + mainSVG.call(this.svgZoom.on('zoom', () => { // D3 Zoom also handles panning + this.onZoom(currentEvent.transform); + })).on('dblclick.zoom', null); // Ignore the double-click zoom event + + this.listenerGraphCounter = this.props.graphData.graphCounter; + } + } + } + + componentWillUnmount() { + this.isGraphMounted = false; + + let nodes = select('.fdgMainSvg').select('.fdgMainG') + .selectAll('.aai-entity-node'); + let nodeButtons = nodes.selectAll('.node-button'); + + nodes.on('click', null); + nodeButtons.on('click', null); + + let mainSVG = select('.fdgMainSvg'); + + mainSVG.call(this.svgZoom.on('zoom', null)).on('dblclick.zoom', null); + mainSVG.call(drag().on('drag', null)); + } + + updateSimulationForce() { + this.simulation.force('link', forceLink()); + this.simulation.force('link').id((d) => { + return d.id; + }); + this.simulation.force('link').strength(0.3); + this.simulation.force('link').distance(100); + + this.simulation.force('charge', forceManyBody()); + this.simulation.force('charge').strength(-1250); + this.simulation.alpha(1); + + this.simulation.force('center', + forceCenter(this.props.viewWidth / 2, this.props.viewHeight / 2)); + } + + resetTransform() { + if (this.isGraphMounted) { + this.setState(() => { + return { + mainGroupTransform: zoomIdentity + }; + }); + } + } + + applyBufferDataToState() { + this.nodeIndexTracker.clear(); + + let newNodes = []; + this.nodeBuffer.map((node, i) => { + let nodeProps = this.createNodePropForState(node); + + if (nodeProps.meta.nodeMeta.className === NodeConstants.SELECTED_NODE_CLASS_NAME || + nodeProps.meta.nodeMeta.className === NodeConstants.SELECTED_SEARCHED_NODE_CLASS_NAME) { + + this.nodeButtonDatum[0].data = nodeProps.meta; + + nodeProps = { + ...nodeProps, + buttons: [this.nodeButtonDatum[0].isSelected] + }; + } + + newNodes.push(this.nodeFactory.buildNode(nodeProps.meta.nodeMeta.className, nodeProps)); + + this.nodeIndexTracker.set(node.id, i); + }); + + let newLinks = []; + this.linkBuffer.map((link) => { + let key = link.id; + let linkProps = this.createLinkPropForState(link); + newLinks.push(this.visualElementFactory.createSvgLine(linkProps, key)); + }); + + if (this.isGraphMounted) { + this.setState(() => { + return { + nodes: newNodes, links: newLinks + }; + }); + } + } + + createNodePropForState(nodeData) { + return { + renderProps: { + key: nodeData.id, x: nodeData.x, y: nodeData.y + }, meta: { + ...nodeData + } + }; + } + + createLinkPropForState(linkData) { + return { + className: 'aai-entity-link', + x1: linkData.source.x, + y1: linkData.source.y, + x2: linkData.target.x, + y2: linkData.target.y + }; + } + + startSimulation(graphData) { + this.nodeFactory.setNodeMeta(graphData.graphMeta); + + // Experiment with removing length = 0... might not be needed as new array + // assignment will likely destroy old reference + this.nodeBuffer.length = 0; + this.nodeBuffer = Array.from(graphData.nodeDataArray); + this.linkBuffer.length = 0; + this.linkBuffer = Array.from(graphData.linkDataArray); + this.nodeDatum.length = 0; + this.nodeDatum = Array.from(graphData.nodeDataArray); + + this.nodeButtonDatum.length = 0; + + let isNodeDetailsSelected = true; + this.nodeButtonDatum.push({ + name: NodeConstants.ICON_ELLIPSES, isSelected: isNodeDetailsSelected + }); + + if (isNodeDetailsSelected) { + this.currentlySelectedNodeButton = NodeConstants.ICON_ELLIPSES; + } + + this.updateSimulationForce(); + + this.simulation.nodes(this.nodeBuffer); + this.simulation.force('link').links(this.linkBuffer); + this.simulation.on('tick', this.simulationTick); + this.simulation.restart(); + } + + simulationComplete() { + this.intervalTimer.stop(); + this.applyBufferDataToState(); + } + + simulationTick() { + this.intervalTimer.restart(this.applyBufferDataToState, simulationKeys.DATA_COPY_INTERVAL); + this.simulation.on('tick', null); + } + + nodeSelected(datum) { + if (this.props.nodeSelectedCallback) { + this.props.nodeSelectedCallback(datum); + } + + let didUpdateNew = false; + let didUpdatePrevious = false; + let isSameNodeSelected = true; + + // Check to see if a default node was previously selected + let selectedDefaultNode = select('.fdgMainSvg').select('.fdgMainG') + .selectAll('.aai-entity-node') + .filter('.selected-node'); + if (!selectedDefaultNode.empty()) { + if (selectedDefaultNode.datum().id !== datum.id) { + this.nodeBuffer[selectedDefaultNode.datum().index].nodeMeta.className = + NodeConstants.GENERAL_NODE_CLASS_NAME; + didUpdatePrevious = true; + isSameNodeSelected = false; + } + } + + // Check to see if a searched node was previously selected + let selectedSearchedNode = select('.fdgMainSvg').select('.fdgMainG') + .selectAll('.aai-entity-node') + .filter('.selected-search-node'); + if (!selectedSearchedNode.empty()) { + if (selectedSearchedNode.datum().id !== datum.id) { + this.nodeBuffer[selectedSearchedNode.datum().index].nodeMeta.className = + NodeConstants.SEARCHED_NODE_CLASS_NAME; + didUpdatePrevious = true; + isSameNodeSelected = false; + } + } + + if (!isSameNodeSelected) { + let newlySelectedNode = select('.fdgMainSvg').select('.fdgMainG') + .selectAll('.aai-entity-node') + .filter((d) => { + return (datum.id === d.id); + }); + if (!newlySelectedNode.empty()) { + + if (newlySelectedNode.datum().nodeMeta.searchTarget) { + this.nodeBuffer[newlySelectedNode.datum().index].nodeMeta.className = + NodeConstants.SELECTED_SEARCHED_NODE_CLASS_NAME; + } else { + this.nodeBuffer[newlySelectedNode.datum().index].nodeMeta.className = + NodeConstants.SELECTED_NODE_CLASS_NAME; + } + didUpdateNew = true; + } + } + + if (didUpdatePrevious && didUpdateNew) { + this.newNodeSelected = true; + this.applyBufferDataToState(); + } + } + + onZoom(eventTransform) { + if (this.isGraphMounted) { + this.setState(() => { + return { + mainGroupTransform: eventTransform + }; + }); + } + } + + onGraphDrag(xAndYCoords) { + let translate = `translate(${xAndYCoords.x}, ${xAndYCoords.y})`; + let oldTransform = this.state.mainGroupTransform; + if (this.isGraphMounted) { + this.setState(() => { + return { + ...oldTransform, translate + }; + }); + } + } + + onNodeDrag(datum, xAndYCoords) { + let nodeIndex = this.nodeIndexTracker.get(datum.id); + if (this.nodeBuffer[nodeIndex]) { + this.nodeBuffer[nodeIndex].x = xAndYCoords[0]; + this.nodeBuffer[nodeIndex].y = xAndYCoords[1]; + this.applyBufferDataToState(); + } + } + + addNodeInterpolator(nodeId, key, startingValue, endingValue, duration) { + let numberInterpolator = interpolateNumber(startingValue, endingValue); + let timeNow = now(); + let interpolationObject = { + nodeId: nodeId, key: key, duration: duration, timeCreated: timeNow, method: numberInterpolator + }; + this.interpolators.set(nodeId, interpolationObject); + + if (!this.areInterpolationsRunning) { + this.interpolationTimer.restart(this.runInterpolators, simulationKeys.DATA_COPY_INTERVAL); + this.areInterpolationsRunning = true; + } + } + + runInterpolators() { + // If we have no more interpolators to run then shut'r down! + if (this.interpolators.size === 0) { + this.interpolationTimer.stop(); + this.areInterpolationsRunning = false; + } + + let iterpolatorsComplete = []; + // Apply interpolation values + this.interpolators.forEach((interpolator) => { + let nodeIndex = this.nodeIndexTracker.get(interpolator.nodeId); + if (nodeIndex) { + let elapsedTime = now() - interpolator.timeCreated; + // Normalize t as D3's interpolateNumber needs a value between 0 and 1 + let t = elapsedTime / interpolator.duration; + if (t >= 1) { + t = 1; + iterpolatorsComplete.push(interpolator.nodeId); + } + this.nodeBuffer[nodeIndex][interpolator.key] = interpolator.method(t); + } + }); + + // Remove any interpolators that are complete + if (iterpolatorsComplete.length > 0) { + for (let i = 0; i < iterpolatorsComplete.length; i++) { + this.interpolators.delete(iterpolatorsComplete[i]); + } + } + + this.applyBufferDataToState(); + } + + render() { + // We will be using these values veru shortly, commenting out for eslint + // reasons so we can build for PV let {viewWidth, viewHeight} = this.props; + let {nodes, links, mainGroupTransform} = this.state; + + return ( + <div className='ts-force-selected-graph'> + <svg className={'fdgMainSvg'} width='100%' height='100%'> + <rect className={'fdgMainView'} x='0.5' y='0.5' width='99%' + height='99%' fill='none'/> + <filter id='selected-node-drop-shadow'> + <feGaussianBlur in='SourceAlpha' stdDeviation='2.2'/> + <feOffset dx='-1' dy='1' result='offsetblur'/> + <feFlood floodColor='rgba(0,0,0,0.5)'/> + <feComposite in2='offsetblur' operator='in'/> + <feMerge> + <feMergeNode/> + <feMergeNode in='SourceGraphic'/> + </feMerge> + </filter> + <g className={'fdgMainG'} transform={mainGroupTransform}> + {links} + {nodes} + </g> + </svg> + </div> + ); + } + + static graphCounter = 0; + + static generateNewProps(nodeArray, linkArray, metaData) { + ForceDirectedGraph.graphCounter += 1; + return { + graphCounter: ForceDirectedGraph.graphCounter, + nodeDataArray: nodeArray, + linkDataArray: linkArray, + graphMeta: metaData + }; + } +} + +export default ForceDirectedGraph; |