src/components/InteractiveForceGraph.js (180 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 { window } from 'global';
import ForceGraph, { isNode, isLink } from './ForceGraph';
import { nodeId } from '../utils/d3-force';
const isTouch = window && 'ontouchstart' in window;
const selectedNodeShape = PropTypes.shape({
id: PropTypes.string,
});
export default class InteractiveForceGraph extends PureComponent {
static get propTypes() {
return Object.assign({
selectedNode: selectedNodeShape,
defaultSelectedNode: selectedNodeShape,
highlightDependencies: PropTypes.bool,
opacityFactor: PropTypes.number,
onSelectNode: PropTypes.func,
onDeselectNode: PropTypes.func,
}, ForceGraph.propTypes);
}
static get defaultProps() {
return {
className: '',
defaultSelectedNode: null,
opacityFactor: 4,
onSelectNode() {},
onDeselectNode() {},
};
}
constructor(props) {
super(props);
this.state = {
hoveredNode: null,
selectedNode: props.selectedNode || props.defaultSelectedNode,
};
}
componentWillReceiveProps(nextProps) {
if (Object.prototype.hasOwnProperty.call(nextProps, 'selectedNode')) {
this.setState({ selectedNode: nextProps.selectedNode });
}
}
onHoverNode(event, hoveredNode) {
if (!isTouch) {
this.setState({ hoveredNode });
}
}
onBlurNode() {
this.setState({ hoveredNode: null });
}
onClickNode(event, selectedNode) {
const { onDeselectNode, onSelectNode } = this.props;
const previousNode = this.state.selectedNode;
// if the user clicked the same node that was already
// selected, deselect it.
if (previousNode && nodeId(previousNode) === nodeId(selectedNode)) {
this.setState({ selectedNode: null });
onDeselectNode(event, selectedNode);
} else {
this.setState({ selectedNode });
onSelectNode(event, selectedNode);
}
}
render() {
const {
highlightDependencies,
opacityFactor,
children,
className,
selectedNode: propsSelectedNode,
...spreadableProps
} = this.props;
const { hoveredNode, selectedNode: stateSelectedNode } = this.state;
const { links } = ForceGraph.getDataFromChildren(children);
const selectedNode = propsSelectedNode || stateSelectedNode;
const applyOpacity = (opacity = 1) => opacity / opacityFactor;
const createEventHandler = (name, node, fn) => (event) => {
this[name](event, node);
if (fn) {
fn(event);
}
};
const areNodesRelatives = (node1, node2) =>
node1 && node2 && links.findIndex(link =>
link.value > 0 && (
(link.source === nodeId(node1) && link.target === nodeId(node2)) ||
(link.source === nodeId(node2) && link.target === nodeId(node1))
)
) > -1;
const isNodeHighlighted = (focusedNode, node) =>
focusedNode && (
(nodeId(focusedNode) === nodeId(node)) ||
(selectedNode && nodeId(selectedNode) === nodeId(node)) ||
(highlightDependencies && areNodesRelatives(node, selectedNode || focusedNode))
);
const isLinkHighlighted = (focusedNode, link) =>
focusedNode && highlightDependencies && link.value > 0 &&
(nodeId(focusedNode) === link.source ||
nodeId(focusedNode) === link.target);
const fontSizeForNode = node =>
(selectedNode && nodeId(node) === nodeId(selectedNode) ? 14 : 10);
const fontWeightForNode = node =>
(selectedNode && nodeId(node) === nodeId(selectedNode) ? 700 : null);
const showLabelForNode = node =>
isNodeHighlighted(selectedNode, node) ||
isNodeHighlighted(hoveredNode, node);
const opacityForNode = (node, origOpacity = 1) => {
if (
highlightDependencies && selectedNode &&
!isNodeHighlighted(selectedNode, node) &&
!isNodeHighlighted(hoveredNode, node)
) {
return applyOpacity(origOpacity / 4);
} else if (
(selectedNode &&
!isNodeHighlighted(selectedNode, node) &&
!isNodeHighlighted(hoveredNode, node)
) || (
hoveredNode && !isNodeHighlighted(hoveredNode, node)
)
) {
return applyOpacity(origOpacity);
}
return origOpacity;
};
const opacityForLink = (link, origOpacity = 1) => {
if (
highlightDependencies ? (
(!selectedNode && hoveredNode && !isLinkHighlighted(hoveredNode, link)) ||
(selectedNode && !isLinkHighlighted(selectedNode, link))
) : (hoveredNode || selectedNode)
) {
return applyOpacity(origOpacity / 4);
}
if (
hoveredNode && !isLinkHighlighted(hoveredNode, link) &&
selectedNode && !isLinkHighlighted(selectedNode, link)
) {
return applyOpacity(origOpacity);
}
return origOpacity;
};
return (
<ForceGraph className={`rv-force__interactive ${className}`} {...spreadableProps}>
{Children.map(children, (child) => {
if (isNode(child)) {
const {
node,
labelStyle,
fontSize = fontSizeForNode(node),
fontWeight = fontWeightForNode(node),
showLabel = showLabelForNode(node),
onMouseEnter,
onMouseLeave,
onClick,
} = child.props;
let { opacity } = child.props;
opacity = opacityForNode(node, opacity);
return cloneElement(child, {
showLabel,
opacity,
labelStyle: {
fontSize,
fontWeight,
opacity,
...labelStyle,
},
onMouseEnter: createEventHandler('onHoverNode', node, onMouseEnter),
onMouseLeave: createEventHandler('onBlurNode', node, onMouseLeave),
onClick: createEventHandler('onClickNode', node, onClick),
});
} else if (isLink(child)) {
const { link } = child.props;
let { opacity } = child.props;
opacity = opacityForLink(link, opacity);
return cloneElement(child, { opacity });
}
return child;
})}
</ForceGraph>
);
}
}