UNPKG

react-d3-graph

Version:

React component to build interactive and configurable graphs with d3 effortlessly

684 lines (593 loc) 23 kB
import React from "react"; import { drag as d3Drag } from "d3-drag"; import { forceLink as d3ForceLink } from "d3-force"; import { select as d3Select, selectAll as d3SelectAll, event as d3Event } from "d3-selection"; import { zoom as d3Zoom } from "d3-zoom"; import CONST from "./graph.const"; import DEFAULT_CONFIG from "./graph.config"; import ERRORS from "../../err"; import { getTargetLeafConnections, toggleLinksMatrixConnections, toggleLinksConnections } from "./collapse.helper"; import { updateNodeHighlightedValue, checkForGraphConfigChanges, checkForGraphElementsChanges, getCenterAndZoomTransformation, initializeGraphState, initializeNodes, } from "./graph.helper"; import { renderGraph } from "./graph.renderer"; import { merge, debounce, throwErr } from "../../utils"; /** * Graph component is the main component for react-d3-graph components, its interface allows its user * to build the graph once the user provides the data, configuration (optional) and callback interactions (also optional). * The code for the [live example](https://danielcaldas.github.io/react-d3-graph/sandbox/index.html) * can be consulted [here](https://github.com/danielcaldas/react-d3-graph/blob/master/sandbox/Sandbox.jsx) * @example * import { Graph } from 'react-d3-graph'; * * // graph payload (with minimalist structure) * const data = { * nodes: [ * {id: 'Harry'}, * {id: 'Sally'}, * {id: 'Alice'} * ], * links: [ * {source: 'Harry', target: 'Sally'}, * {source: 'Harry', target: 'Alice'}, * ] * }; * * // the graph configuration, you only need to pass down properties * // that you want to override, otherwise default ones will be used * const myConfig = { * nodeHighlightBehavior: true, * node: { * color: 'lightgreen', * size: 120, * highlightStrokeColor: 'blue' * }, * link: { * highlightColor: 'lightblue' * } * }; * * // Callback to handle click on the graph. * // @param {Object} event click dom event * const onClickGraph = function(event) { * window.alert('Clicked the graph background'); * }; * * const onClickNode = function(nodeId, node) { * window.alert('Clicked node ${nodeId} in position (${node.x}, ${node.y})'); * }; * * const onDoubleClickNode = function(nodeId, node) { * window.alert('Double clicked node ${nodeId} in position (${node.x}, ${node.y})'); * }; * * const onRightClickNode = function(event, nodeId, node) { * window.alert('Right clicked node ${nodeId} in position (${node.x}, ${node.y})'); * }; * * const onMouseOverNode = function(nodeId, node) { * window.alert(`Mouse over node ${nodeId} in position (${node.x}, ${node.y})`); * }; * * const onMouseOutNode = function(nodeId, node) { * window.alert(`Mouse out node ${nodeId} in position (${node.x}, ${node.y})`); * }; * * const onClickLink = function(source, target) { * window.alert(`Clicked link between ${source} and ${target}`); * }; * * const onRightClickLink = function(event, source, target) { * window.alert('Right clicked link between ${source} and ${target}'); * }; * * const onMouseOverLink = function(source, target) { * window.alert(`Mouse over in link between ${source} and ${target}`); * }; * * const onMouseOutLink = function(source, target) { * window.alert(`Mouse out link between ${source} and ${target}`); * }; * * const onNodePositionChange = function(nodeId, x, y) { * window.alert(`Node ${nodeId} moved to new position x= ${x} y= ${y}`); * }; * * // Callback that's called whenever the graph is zoomed in/out * // @param {number} previousZoom the previous graph zoom * // @param {number} newZoom the new graph zoom * const onZoomChange = function(previousZoom, newZoom) { * window.alert(`Graph is now zoomed at ${newZoom} from ${previousZoom}`); * }; * * * <Graph * id='graph-id' // id is mandatory, if no id is defined rd3g will throw an error * data={data} * config={myConfig} * onClickGraph={onClickGraph} * onClickNode={onClickNode} * onDoubleClickNode={onDoubleClickNode} * onRightClickNode={onRightClickNode} * onClickLink={onClickLink} * onRightClickLink={onRightClickLink} * onMouseOverNode={onMouseOverNode} * onMouseOutNode={onMouseOutNode} * onMouseOverLink={onMouseOverLink} * onMouseOutLink={onMouseOutLink} * onNodePositionChange={onNodePositionChange} * onZoomChange={onZoomChange}/> */ export default class Graph extends React.Component { /** * Obtain a set of properties which will be used to perform the focus and zoom animation if * required. In case there's not a focus and zoom animation in progress, it should reset the * transition duration to zero and clear transformation styles. * @returns {Object} - Focus and zoom animation properties. */ _generateFocusAnimationProps = () => { // In case an older animation was still not complete, clear previous timeout to ensure the new one is not cancelled if (this.state.enableFocusAnimation) { if (this.focusAnimationTimeout) { clearTimeout(this.focusAnimationTimeout); } this.focusAnimationTimeout = setTimeout( () => this.setState({ enableFocusAnimation: false }), this.state.config.focusAnimationDuration * 1000 ); } const transitionDuration = this.state.enableFocusAnimation ? this.state.config.focusAnimationDuration : 0; return { style: { transitionDuration: `${transitionDuration}s` }, transform: this.state.focusTransformation, }; }; /** * This method runs {@link d3-force|https://github.com/d3/d3-force} * against the current graph. * @returns {undefined} */ _graphLinkForceConfig() { const forceLink = d3ForceLink(this.state.d3Links) .id(l => l.id) .distance(this.state.config.d3.linkLength) .strength(this.state.config.d3.linkStrength); this.state.simulation.force(CONST.LINK_CLASS_NAME, forceLink); } /** * This method runs {@link d3-drag|https://github.com/d3/d3-drag} * against the current graph. * @returns {undefined} */ _graphNodeDragConfig() { const customNodeDrag = d3Drag() .on("start", this._onDragStart) .on("drag", this._onDragMove) .on("end", this._onDragEnd); d3Select(`#${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`) .selectAll(".node") .call(customNodeDrag); } /** * Sets d3 tick function and configures other d3 stuff such as forces and drag events. * Whenever called binds Graph component state with d3. * @returns {undefined} */ _graphBindD3ToReactComponent() { if (!this.state.config.d3.disableLinkForce) { this.state.simulation.nodes(this.state.d3Nodes).on("tick", this._tick); this._graphLinkForceConfig(); } if (!this.state.config.freezeAllDragEvents) { this._graphNodeDragConfig(); } } /** * Handles d3 drag 'end' event. * @returns {undefined} */ _onDragEnd = () => { this.isDraggingNode = false; if (this.state.draggedNode) { this.onNodePositionChange(this.state.draggedNode); this._tick({ draggedNode: null }); } !this.state.config.staticGraph && this.state.config.automaticRearrangeAfterDropNode && this.state.simulation.alphaTarget(this.state.config.d3.alphaTarget).restart(); }; /** * Handles d3 'drag' event. * {@link https://github.com/d3/d3-drag/blob/master/README.md#drag_subject|more about d3 drag} * @param {Object} ev - if not undefined it will contain event data. * @param {number} index - index of the node that is being dragged. * @param {Array.<Object>} nodeList - array of d3 nodes. This list of nodes is provided by d3, each * node contains all information that was previously fed by rd3g. * @returns {undefined} */ _onDragMove = (ev, index, nodeList) => { const id = nodeList[index].id; if (!this.state.config.staticGraph) { // this is where d3 and react bind let draggedNode = this.state.nodes[id]; draggedNode.oldX = draggedNode.x; draggedNode.oldY = draggedNode.y; draggedNode.x += d3Event.dx; draggedNode.y += d3Event.dy; // set nodes fixing coords fx and fy draggedNode["fx"] = draggedNode.x; draggedNode["fy"] = draggedNode.y; this._tick({ draggedNode }); } }; /** * Handles d3 drag 'start' event. * @returns {undefined} */ _onDragStart = () => { this.isDraggingNode = true; this.pauseSimulation(); if (this.state.enableFocusAnimation) { this.setState({ enableFocusAnimation: false }); } }; /** * Sets nodes and links highlighted value. * @param {string} id - the id of the node to highlight. * @param {boolean} [value=false] - the highlight value to be set (true or false). * @returns {undefined} */ _setNodeHighlightedValue = (id, value = false) => this._tick(updateNodeHighlightedValue(this.state.nodes, this.state.links, this.state.config, id, value)); /** * The tick function simply calls React set state in order to update component and render nodes * along time as d3 calculates new node positioning. * @param {Object} state - new state to pass on. * @param {Function} [cb] - optional callback to fed in to {@link setState()|https://reactjs.org/docs/react-component.html#setstate}. * @returns {undefined} */ _tick = (state = {}, cb) => (cb ? this.setState(state, cb) : this.setState(state)); /** * Configures zoom upon graph with default or user provided values.<br/> * NOTE: in order for users to be able to double click on nodes, we * are disabling the native dblclick.zoom from d3 that performs a zoom * whenever a user double clicks on top of the graph. * {@link https://github.com/d3/d3-zoom#zoom} * @returns {undefined} */ _zoomConfig = () => { const selector = d3Select(`#${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`); const zoomObject = d3Zoom().scaleExtent([this.state.config.minZoom, this.state.config.maxZoom]); if (!this.state.config.freezeAllDragEvents) { zoomObject.on("zoom", this._zoomed); } if (this.state.config.initialZoom !== null) { zoomObject.scaleTo(selector, this.state.config.initialZoom); } // avoid double click on graph to trigger zoom // for more details consult: https://github.com/danielcaldas/react-d3-graph/pull/202 selector.call(zoomObject).on("dblclick.zoom", null); }; /** * Handler for 'zoom' event within zoom config. * @returns {Object} returns the transformed elements within the svg graph area. */ _zoomed = () => { const transform = d3Event.transform; d3SelectAll(`#${this.state.id}-${CONST.GRAPH_CONTAINER_ID}`).attr("transform", transform); this.state.config.panAndZoom && this.setState({ transform: transform.k }); // only send zoom change events if the zoom has changed (_zoomed() also gets called when panning) if (this.debouncedOnZoomChange && this.state.previousZoom !== transform.k) { this.debouncedOnZoomChange(this.state.previousZoom, transform.k); this.setState({ previousZoom: transform.k }); } }; /** * Calls the callback passed to the component. * @param {Object} e - The event of onClick handler. * @returns {undefined} */ onClickGraph = e => { if (this.state.enableFocusAnimation) { this.setState({ enableFocusAnimation: false }); } // Only trigger the graph onClickHandler, if not clicked a node or link. // toUpperCase() is added as a precaution, as the documentation says tagName should always // return in UPPERCASE, but chrome returns lowercase const tagName = e.target && e.target.tagName; const name = e?.target?.attributes?.name?.value; const svgContainerName = `svg-container-${this.state.id}`; if (tagName.toUpperCase() === "SVG" && name === svgContainerName) { this.props.onClickGraph && this.props.onClickGraph(e); } }; /** * Collapses the nodes, then checks if the click is doubled and calls the callback passed to the component. * @param {string} clickedNodeId - The id of the node where the click was performed. * @returns {undefined} */ onClickNode = clickedNodeId => { const clickedNode = this.state.nodes[clickedNodeId]; if (this.state.config.collapsible) { const leafConnections = getTargetLeafConnections(clickedNodeId, this.state.links, this.state.config); const links = toggleLinksMatrixConnections(this.state.links, leafConnections, this.state.config); const d3Links = toggleLinksConnections(this.state.d3Links, links); const firstLeaf = leafConnections?.["0"]; let isExpanding = false; if (firstLeaf) { const visibility = links[firstLeaf.source][firstLeaf.target]; isExpanding = visibility === 1; } this._tick( { links, d3Links, }, () => { this.props.onClickNode && this.props.onClickNode(clickedNodeId, clickedNode); if (isExpanding) { this._graphNodeDragConfig(); } } ); } else { if (!this.nodeClickTimer) { this.nodeClickTimer = setTimeout(() => { this.props.onClickNode && this.props.onClickNode(clickedNodeId, clickedNode); this.nodeClickTimer = null; }, CONST.TTL_DOUBLE_CLICK_IN_MS); } else { this.props.onDoubleClickNode && this.props.onDoubleClickNode(clickedNodeId, clickedNode); this.nodeClickTimer = clearTimeout(this.nodeClickTimer); } } }; /** * Handles right click event on a node. * @param {Object} event - Right click event. * @param {string} id - id of the node that participates in the event. * @returns {undefined} */ onRightClickNode = (event, id) => { const clickedNode = this.state.nodes[id]; this.props.onRightClickNode && this.props.onRightClickNode(event, id, clickedNode); }; /** * Handles mouse over node event. * @param {string} id - id of the node that participates in the event. * @returns {undefined} */ onMouseOverNode = id => { if (this.isDraggingNode) { return; } const clickedNode = this.state.nodes[id]; this.props.onMouseOverNode && this.props.onMouseOverNode(id, clickedNode); this.state.config.nodeHighlightBehavior && this._setNodeHighlightedValue(id, true); }; /** * Handles mouse out node event. * @param {string} id - id of the node that participates in the event. * @returns {undefined} */ onMouseOutNode = id => { if (this.isDraggingNode) { return; } const clickedNode = this.state.nodes[id]; this.props.onMouseOutNode && this.props.onMouseOutNode(id, clickedNode); this.state.config.nodeHighlightBehavior && this._setNodeHighlightedValue(id, false); }; /** * Handles mouse over link event. * @param {string} source - id of the source node that participates in the event. * @param {string} target - id of the target node that participates in the event. * @returns {undefined} */ onMouseOverLink = (source, target) => { this.props.onMouseOverLink && this.props.onMouseOverLink(source, target); if (this.state.config.linkHighlightBehavior) { const highlightedLink = { source, target }; this._tick({ highlightedLink }); } }; /** * Handles mouse out link event. * @param {string} source - id of the source node that participates in the event. * @param {string} target - id of the target node that participates in the event. * @returns {undefined} */ onMouseOutLink = (source, target) => { this.props.onMouseOutLink && this.props.onMouseOutLink(source, target); if (this.state.config.linkHighlightBehavior) { const highlightedLink = undefined; this._tick({ highlightedLink }); } }; /** * Handles node position change. * @param {Object} node - an object holding information about the dragged node. * @returns {undefined} */ onNodePositionChange = node => { if (!this.props.onNodePositionChange) { return; } const { id, x, y } = node; this.props.onNodePositionChange(id, x, y); }; /** * Calls d3 simulation.stop().<br/> * {@link https://github.com/d3/d3-force#simulation_stop} * @returns {undefined} */ pauseSimulation = () => this.state.simulation.stop(); /** * This method resets all nodes fixed positions by deleting the properties fx (fixed x) * and fy (fixed y). Following this, a simulation is triggered in order to force nodes to go back * to their original positions (or at least new positions according to the d3 force parameters). * @returns {undefined} */ resetNodesPositions = () => { if (!this.state.config.staticGraph) { let initialNodesState = initializeNodes(this.props.data.nodes); for (let nodeId in this.state.nodes) { let node = this.state.nodes[nodeId]; if (node.fx && node.fy) { Reflect.deleteProperty(node, "fx"); Reflect.deleteProperty(node, "fy"); } if (nodeId in initialNodesState) { let initialNode = initialNodesState[nodeId]; node.x = initialNode.x; node.y = initialNode.y; } } this.state.simulation.alphaTarget(this.state.config.d3.alphaTarget).restart(); this._tick(); } }; /** * Calls d3 simulation.restart().<br/> * {@link https://github.com/d3/d3-force#simulation_restart} * @returns {undefined} */ restartSimulation = () => !this.state.config.staticGraph && this.state.simulation.restart(); constructor(props) { super(props); if (!this.props.id) { throwErr(this.constructor.name, ERRORS.GRAPH_NO_ID_PROP); } this.focusAnimationTimeout = null; this.nodeClickTimer = null; this.isDraggingNode = false; this.state = initializeGraphState(this.props, this.state); this.debouncedOnZoomChange = this.props.onZoomChange ? debounce(this.props.onZoomChange, 100) : null; } /** * @deprecated * `componentWillReceiveProps` has a replacement method in react v16.3 onwards. * that is getDerivedStateFromProps. * But one needs to be aware that if an anti pattern of `componentWillReceiveProps` is * in place for this implementation the migration might not be that easy. * See {@link https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html}. * @param {Object} nextProps - props. * @returns {undefined} */ // eslint-disable-next-line UNSAFE_componentWillReceiveProps(nextProps) { const { graphElementsUpdated, newGraphElements } = checkForGraphElementsChanges(nextProps, this.state); const state = graphElementsUpdated ? initializeGraphState(nextProps, this.state) : this.state; const newConfig = nextProps.config || {}; const { configUpdated, d3ConfigUpdated } = checkForGraphConfigChanges(nextProps, this.state); const config = configUpdated ? merge(DEFAULT_CONFIG, newConfig) : this.state.config; // in order to properly update graph data we need to pause eventual d3 ongoing animations newGraphElements && this.pauseSimulation(); const transform = newConfig.panAndZoom !== this.state.config.panAndZoom ? 1 : this.state.transform; const focusedNodeId = nextProps.data.focusedNodeId; const d3FocusedNode = this.state.d3Nodes.find(node => `${node.id}` === `${focusedNodeId}`); const containerElId = `${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`; const focusTransformation = getCenterAndZoomTransformation(d3FocusedNode, this.state.config, containerElId) || this.state.focusTransformation; const enableFocusAnimation = this.props.data.focusedNodeId !== nextProps.data.focusedNodeId; // if we're given a function to call when the zoom changes, we create a debounced version of it // this is because this function gets called in very rapid succession when zooming if (nextProps.onZoomChange) { this.debouncedOnZoomChange = debounce(nextProps.onZoomChange, 100); } this.setState({ ...state, config, configUpdated, d3ConfigUpdated, newGraphElements, transform, focusedNodeId, enableFocusAnimation, focusTransformation, }); } componentDidUpdate() { // if the property staticGraph was activated we want to stop possible ongoing simulation const shouldPause = this.state.config.staticGraph || this.state.config.staticGraphWithDragAndDrop; if (shouldPause) { this.pauseSimulation(); } if (!this.state.config.staticGraph && (this.state.newGraphElements || this.state.d3ConfigUpdated)) { this._graphBindD3ToReactComponent(); if (!this.state.config.staticGraphWithDragAndDrop) { this.restartSimulation(); } this.setState({ newGraphElements: false, d3ConfigUpdated: false }); } else if (this.state.configUpdated) { this._graphNodeDragConfig(); } if (this.state.configUpdated) { this._zoomConfig(); this.setState({ configUpdated: false }); } } componentDidMount() { if (!this.state.config.staticGraph) { this._graphBindD3ToReactComponent(); } // graph zoom and drag&drop all network this._zoomConfig(); } componentWillUnmount() { this.pauseSimulation(); if (this.nodeClickTimer) { clearTimeout(this.nodeClickTimer); this.nodeClickTimer = null; } if (this.focusAnimationTimeout) { clearTimeout(this.focusAnimationTimeout); this.focusAnimationTimeout = null; } } render() { const { nodes, links, defs } = renderGraph( this.state.nodes, { onClickNode: this.onClickNode, onDoubleClickNode: this.onDoubleClickNode, onRightClickNode: this.onRightClickNode, onMouseOverNode: this.onMouseOverNode, onMouseOut: this.onMouseOutNode, }, this.state.d3Links, this.state.links, { onClickLink: this.props.onClickLink, onRightClickLink: this.props.onRightClickLink, onMouseOverLink: this.onMouseOverLink, onMouseOutLink: this.onMouseOutLink, }, this.state.config, this.state.highlightedNode, this.state.highlightedLink, this.state.transform ); const svgStyle = { height: this.state.config.height, width: this.state.config.width, }; const containerProps = this._generateFocusAnimationProps(); return ( <div id={`${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`}> <svg name={`svg-container-${this.state.id}`} style={svgStyle} onClick={this.onClickGraph}> {defs} <g id={`${this.state.id}-${CONST.GRAPH_CONTAINER_ID}`} {...containerProps}> {links} {nodes} </g> </svg> </div> ); } }