almus-d3-graph
Version:
React component to build interactive and configurable graphs with d3 effortlessly
545 lines (478 loc) • 19.8 kB
JSX
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 * as collapseHelper from "./collapse.helper";
import * as graphHelper from "./graph.helper";
import * as graphRenderer from "./graph.renderer";
import utils 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'
* }
* };
*
* // graph event callbacks
* const onClickGraph = function() {
* window.alert('Clicked the graph background');
* };
*
* const onClickNode = function(nodeId) {
* window.alert('Clicked node ${nodeId}');
* };
*
* const onDoubleClickNode = function(nodeId) {
* window.alert('Double clicked node ${nodeId}');
* };
*
* const onRightClickNode = function(event, nodeId) {
* window.alert('Right clicked node ${nodeId}');
* };
*
* const onMouseOverNode = function(nodeId) {
* window.alert(`Mouse over node ${nodeId}`);
* };
*
* const onMouseOutNode = function(nodeId) {
* window.alert(`Mouse out node ${nodeId}`);
* };
*
* 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}`);
* };
*
* <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}/>
*/
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 = () => {
const { focusedNodeId } = this.state;
// 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: focusedNodeId ? this.state.focusTransformation : null,
};
};
/**
* Sets d3 tick function and configures other d3 stuff such as forces and drag events.
* @returns {undefined}
*/
_graphForcesConfig() {
this.state.simulation.nodes(this.state.d3Nodes).on("tick", this._tick);
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);
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);
}
/**
* Handles d3 drag 'end' event.
* @returns {undefined}
*/
_onDragEnd = () =>
!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.x += d3Event.dx;
draggedNode.y += d3Event.dy;
// set nodes fixing coords fx and fy
draggedNode["fx"] = draggedNode.x;
draggedNode["fy"] = draggedNode.y;
this._tick();
}
};
/**
* Handles d3 drag 'start' event.
* @returns {undefined}
*/
_onDragStart = () => {
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(
graphHelper.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 = () => {
d3Select(`#${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`)
.call(
d3Zoom()
.scaleExtent([this.state.config.minZoom, this.state.config.maxZoom])
.on("zoom", this._zoomed)
)
.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 });
};
/**
* 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 && e.target.attributes && e.target.attributes.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();
}
};
/**
* 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 => {
if (this.state.config.collapsible) {
const leafConnections = collapseHelper.getTargetLeafConnections(
clickedNodeId,
this.state.links,
this.state.config
);
const links = collapseHelper.toggleLinksMatrixConnections(
this.state.links,
leafConnections,
this.state.config
);
const d3Links = collapseHelper.toggleLinksConnections(this.state.d3Links, links);
this._tick(
{
links,
d3Links,
},
() => this.props.onClickNode && this.props.onClickNode(clickedNodeId)
);
} else {
if (!this.nodeClickTimer) {
this.nodeClickTimer = setTimeout(() => {
this.props.onClickNode && this.props.onClickNode(clickedNodeId);
this.nodeClickTimer = null;
}, CONST.TTL_DOUBLE_CLICK_IN_MS);
} else {
this.props.onDoubleClickNode && this.props.onDoubleClickNode(clickedNodeId);
this.nodeClickTimer = clearTimeout(this.nodeClickTimer);
}
}
};
/**
* Handles mouse over node event.
* @param {string} id - id of the node that participates in the event.
* @returns {undefined}
*/
onMouseOverNode = id => {
this.props.onMouseOverNode && this.props.onMouseOverNode(id);
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 => {
this.props.onMouseOutNode && this.props.onMouseOutNode(id);
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 });
}
};
/**
* 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) {
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");
}
}
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) {
utils.throwErr(this.constructor.name, ERRORS.GRAPH_NO_ID_PROP);
}
this.focusAnimationTimeout = null;
this.state = graphHelper.initializeGraphState(this.props, this.state);
}
/**
* @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}
*/
componentWillReceiveProps(nextProps) {
const { graphElementsUpdated, newGraphElements } = graphHelper.checkForGraphElementsChanges(
nextProps,
this.state
);
const state = graphElementsUpdated ? graphHelper.initializeGraphState(nextProps, this.state) : this.state;
const newConfig = nextProps.config || {};
const { configUpdated, d3ConfigUpdated } = graphHelper.checkForGraphConfigChanges(nextProps, this.state);
const config = configUpdated ? utils.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 focusTransformation = graphHelper.getCenterAndZoomTransformation(d3FocusedNode, this.state.config);
const enableFocusAnimation = this.props.data.focusedNodeId !== nextProps.data.focusedNodeId;
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._graphForcesConfig();
if (!this.state.config.staticGraphWithDragAndDrop) {
this.restartSimulation();
}
this.setState({ newGraphElements: false, d3ConfigUpdated: false });
}
if (this.state.configUpdated) {
this._zoomConfig();
this.setState({ configUpdated: false });
}
}
componentDidMount() {
if (!this.state.config.staticGraph) {
this._graphForcesConfig();
}
// graph zoom and drag&drop all network
this._zoomConfig();
}
componentWillUnmount() {
this.pauseSimulation();
this.nodeClickTimer && clearTimeout(this.nodeClickTimer);
}
render() {
const { nodes, links, defs } = graphRenderer.renderGraph(
this.state.nodes,
{
onClickNode: this.onClickNode,
onDoubleClickNode: this.onDoubleClickNode,
onRightClickNode: this.props.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>
);
}
}