src/components/ForceGraph.js (254 lines of code) (raw):
// Copyright (c) 2017 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import React, { PureComponent, Children, cloneElement } from 'react';
import PropTypes from 'prop-types';
import reduce from 'lodash.reduce';
import './ForceGraph.css';
import * as forceUtils from '../utils/d3-force';
import * as rafUtils from '../utils/raf';
import ZoomableSVGGroup from './ZoomableSVGGroup';
import simulationPropTypes, { DEFAULT_SIMULATION_PROPS } from '../propTypes/simulation';
export function isNode(child) {
return child.props && child.props.node;
}
export function isLink(child) {
return child.props && child.props.link;
}
const zoomPropTypes = PropTypes.shape({
zoomSpeed: PropTypes.number,
minScale: PropTypes.number,
maxScale: PropTypes.number,
panLimit: PropTypes.number,
onZoom: PropTypes.func,
onPan: PropTypes.func,
});
export default class ForceGraph extends PureComponent {
static get propTypes() {
return {
children: PropTypes.any,
className: PropTypes.string,
// zoom and pan
zoom: PropTypes.bool,
zoomOptions: zoomPropTypes,
// create custom simulations
createSimulation: PropTypes.func,
updateSimulation: PropTypes.func,
simulationOptions: simulationPropTypes,
// adjust label display
labelAttr: PropTypes.string,
labelOffset: PropTypes.objectOf(PropTypes.func),
showLabels: PropTypes.bool,
};
}
static get defaultProps() {
return {
createSimulation: forceUtils.createSimulation,
updateSimulation: forceUtils.updateSimulation,
zoom: false,
labelAttr: 'id',
simulationOptions: DEFAULT_SIMULATION_PROPS,
labelOffset: {
x: ({ radius = 5 }) => radius / 2,
y: ({ radius = 5 }) => -radius / 4,
},
showLabels: false,
zoomOptions: {},
};
}
static getDataFromChildren(children) {
const data = { nodes: [], links: [] };
Children.forEach(children, (child) => {
if (isNode(child)) {
data.nodes.push(child.props.node);
} else if (isLink(child)) {
data.links.push(child.props.link);
}
});
return data;
}
/**
* return a map of nodeIds to node positions.
* @param {object} simulation - d3-force simulation
* @return {object} map of nodeIds to positions
*/
static getNodePositions(simulation) {
return simulation.nodes().reduce(
(obj, node) => Object.assign(obj, {
[forceUtils.nodeId(node)]: {
cx: node.fx || node.x,
cy: node.fy || node.y,
},
}),
{}
);
}
/**
* return a map of nodeIds to node positions.
* @param {object} simulation - d3-force simulation
* @return {object} map of linkIds to positions
*/
static getLinkPositions(simulation) {
return simulation.force('link').links().reduce(
(obj, link) => Object.assign(obj, {
[forceUtils.linkId(link)]: {
x1: link.source.x,
y1: link.source.y,
x2: link.target.x,
y2: link.target.y,
},
}),
{}
);
}
constructor(props) {
super(props);
const { createSimulation, simulationOptions } = props;
const data = this.getDataFromChildren();
this.simulation = createSimulation({
...DEFAULT_SIMULATION_PROPS,
...simulationOptions,
data,
});
this.state = {
linkPositions: {},
nodePositions: {},
scale: 1,
};
this.bindSimulationTick();
}
componentDidMount() {
this.updateSimulation();
}
componentWillReceiveProps(nextProps) {
this.lastUpdated = new Date();
this.updateSimulation(nextProps);
}
componentWillUnmount() {
this.unbindSimulationTick();
}
onSimulationTick() {
this.frame = rafUtils.requestAnimationFrame(
this.updatePositions.bind(this)
);
}
onZoom(event, scale, ...args) {
const { zoomOptions: { onZoom: _onZoom = () => {} } } = this.props;
_onZoom(event, scale, ...args);
this.setState({ scale });
}
onPan(...args) {
const { zoomOptions: { onPan: _onPan = () => {} } } = this.props;
_onPan(...args);
}
getDataFromChildren(props = this.props, force = false) {
if (!force && (this.cachedData && new Date() > this.lastUpdated)) {
return this.cachedData;
}
const data = ForceGraph.getDataFromChildren(props.children);
Object.assign(this, { cachedData: data, lastUpdated: new Date() });
return data;
}
bindSimulationTick() {
this.simulation.on('tick', this.updateSimulation.bind(this));
}
unbindSimulationTick() {
this.simulation.on('tick', null);
this.frame = this.frame && rafUtils.cancelAnimationFrame(this.frame);
}
updateSimulation(props = this.props) {
const { simulation } = this;
const { updateSimulation, simulationOptions } = props;
this.simulation = updateSimulation(simulation, {
...DEFAULT_SIMULATION_PROPS,
...simulationOptions,
data: this.getDataFromChildren(props, true),
});
this.onSimulationTick();
}
updatePositions() {
this.setState({
linkPositions: ForceGraph.getLinkPositions(this.simulation),
nodePositions: ForceGraph.getNodePositions(this.simulation),
});
}
scale(number) {
return typeof number === 'number' ? number / this.state.scale : number;
}
render() {
const {
children,
className,
labelAttr,
labelOffset,
showLabels,
simulationOptions,
zoomOptions,
zoom,
} = this.props;
const {
linkPositions,
nodePositions,
} = this.state;
const {
height = DEFAULT_SIMULATION_PROPS.height,
width = DEFAULT_SIMULATION_PROPS.width,
} = simulationOptions;
const nodeElements = [];
const labelElements = [];
const linkElements = [];
const zoomableChildren = [];
const staticChildren = [];
const maxPanWidth = reduce(nodePositions, (maxWidth, { cx }) =>
(maxWidth > Math.abs(cx) ? maxWidth : Math.abs(cx)), 0);
const maxPanHeight = reduce(nodePositions, (maxHeight, { cy }) =>
(maxHeight > Math.abs(cy) ? maxHeight : Math.abs(cy)), 0);
// build up the real children to render by iterating through the provided children
Children.forEach(children, (child, idx) => {
if (isNode(child)) {
const {
node,
showLabel,
labelClass,
labelStyle = {},
strokeWidth,
} = child.props;
const nodePosition = nodePositions[forceUtils.nodeId(node)];
nodeElements.push(cloneElement(child, {
...nodePosition,
scale: this.state.scale,
strokeWidth: this.scale(strokeWidth),
}));
if ((showLabels || showLabel) && nodePosition) {
const { fontSize, ...spreadableLabelStyle } = labelStyle;
labelElements.push(
<text
className={`rv-force__label ${labelClass}`}
key={`${forceUtils.nodeId(node)}-label`}
x={nodePosition.cx + labelOffset.x(node)}
y={nodePosition.cy + labelOffset.y(node)}
fontSize={this.scale(fontSize)}
style={spreadableLabelStyle}
>
{node[labelAttr]}
</text>
);
}
} else if (isLink(child)) {
const { link } = child.props;
const { strokeWidth } = link;
const linkPosition = linkPositions[forceUtils.linkId(link)];
linkElements.push(cloneElement(child, {
...linkPosition,
strokeWidth: this.scale(strokeWidth),
}));
} else {
const { props: { zoomable } } = child;
if (zoom && zoomable) {
zoomableChildren.push(cloneElement(child, { key: child.key || `zoomable-${idx}` }));
} else {
staticChildren.push(cloneElement(child, { key: child.key || `static-${idx}` }));
}
}
});
return (
<svg className={`rv-force__svg ${className}`} width={width} height={height}>
<g className="rv-force__static-elements">{staticChildren}</g>
<ZoomableSVGGroup
disabled={!zoom}
height={maxPanHeight}
width={maxPanWidth}
{...zoomOptions}
onZoom={(...args) => this.onZoom(...args)}
onPan={(...args) => this.onPan(...args)}
>
<g className="rv-force__zoomable-elements">{zoomableChildren}</g>
<g className="rv-force__links">{linkElements}</g>
<g className="rv-force__nodes">{nodeElements}</g>
<g className="rv-force__labels">{labelElements}</g>
</ZoomableSVGGroup>
</svg>
);
}
}