react-tree-graph
Version:
A react library for generating a graphical tree from data using d3
675 lines (657 loc) • 21 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? factory(
exports,
require('@babel/runtime/helpers/extends'),
require('d3-ease'),
require('prop-types'),
require('react'),
require('d3-hierarchy')
)
: typeof define === 'function' && define.amd
? define(
[
'exports',
'@babel/runtime/helpers/extends',
'd3-ease',
'prop-types',
'react',
'd3-hierarchy',
],
factory
)
: ((global =
typeof globalThis !== 'undefined' ? globalThis : global || self),
factory(
(global.ReactTreeGraph = {}),
global._extends,
global.d3,
global.PropTypes,
global.React,
global.d3
));
})(this, function (exports, _extends, d3Ease, PropTypes, React, d3Hierarchy) {
'use strict';
function _interopDefault(e) {
return e && e.__esModule ? e : { default: e };
}
var _extends__default = /*#__PURE__*/ _interopDefault(_extends);
var PropTypes__default = /*#__PURE__*/ _interopDefault(PropTypes);
var React__default = /*#__PURE__*/ _interopDefault(React);
function getTreeData(props) {
const margins = props.margins || {
bottom: 10,
left: props.direction !== 'rtl' ? 20 : 150,
right: props.direction !== 'rtl' ? 150 : 20,
top: 10,
};
const contentWidth = props.width - margins.left - margins.right;
const contentHeight = props.height - margins.top - margins.bottom;
const data = d3Hierarchy.hierarchy(props.data, props.getChildren);
const root = d3Hierarchy.tree().size([contentHeight, contentWidth])(data);
// d3 gives us a top to down tree, but we will display it left to right/right to left, so x and y need to be swapped
const links = root.links().map((link) => ({
...link,
source: {
...link.source,
x:
props.direction !== 'rtl'
? link.source.y
: contentWidth - link.source.y,
y: link.source.x,
},
target: {
...link.target,
x:
props.direction !== 'rtl'
? link.target.y
: contentWidth - link.target.y,
y: link.target.x,
},
}));
const nodes = root.descendants().map((node) => ({
...node,
x: props.direction !== 'rtl' ? node.y : contentWidth - node.y,
y: node.x,
}));
return {
links,
margins,
nodes,
};
}
const regex = /on[A-Z]/;
function wrapper(func, args) {
return (event) => func(event, ...args);
}
// Wraps any event handlers passed in as props with a function that passes additional arguments
function wrapHandlers(props) {
for (
var _len = arguments.length,
args = new Array(_len > 1 ? _len - 1 : 0),
_key = 1;
_key < _len;
_key++
) {
args[_key - 1] = arguments[_key];
}
const handlers = Object.keys(props).filter(
(propName) =>
regex.test(propName) && typeof props[propName] === 'function'
);
const wrappedHandlers = handlers.reduce((acc, handler) => {
acc[handler] = wrapper(props[handler], args);
return acc;
}, {});
return {
...props,
...wrappedHandlers,
};
}
function diagonal(x1, y1, x2, y2) {
return `M${x1},${y1}C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}`;
}
function Link(props) {
const wrappedProps = wrapHandlers(
props.pathProps,
props.source.data[props.keyProp],
props.target.data[props.keyProp]
);
const d = props.pathFunc(props.x1, props.y1, props.x2, props.y2);
return /*#__PURE__*/ React__default['default'].createElement(
'path',
_extends__default['default']({}, wrappedProps, {
d: d,
})
);
}
Link.propTypes = {
source: PropTypes__default['default'].object.isRequired,
target: PropTypes__default['default'].object.isRequired,
keyProp: PropTypes__default['default'].string.isRequired,
x1: PropTypes__default['default'].number.isRequired,
x2: PropTypes__default['default'].number.isRequired,
y1: PropTypes__default['default'].number.isRequired,
y2: PropTypes__default['default'].number.isRequired,
pathFunc: PropTypes__default['default'].func.isRequired,
pathProps: PropTypes__default['default'].object.isRequired,
};
Link.defaultProps = {
pathFunc: diagonal,
};
function Node(props) {
function getTransform() {
return `translate(${props.x}, ${props.y})`;
}
let offset = 0.5;
let nodePropsWithDefaults = props.nodeProps;
switch (props.shape) {
case 'circle':
nodePropsWithDefaults = {
r: 5,
...nodePropsWithDefaults,
};
offset += nodePropsWithDefaults.r;
break;
case 'image':
case 'rect':
nodePropsWithDefaults = {
height: 10,
width: 10,
...nodePropsWithDefaults,
};
nodePropsWithDefaults = {
x: -nodePropsWithDefaults.width / 2,
y: -nodePropsWithDefaults.height / 2,
...nodePropsWithDefaults,
};
offset += nodePropsWithDefaults.width / 2;
break;
}
if (props.direction === 'rtl') {
offset = -offset;
}
const wrappedNodeProps = wrapHandlers(
nodePropsWithDefaults,
props[props.keyProp]
);
const wrappedGProps = wrapHandlers(props.gProps, props[props.keyProp]);
const wrappedTextProps = wrapHandlers(
props.textProps,
props[props.keyProp]
);
const label =
typeof props[props.labelProp] === 'string'
? /*#__PURE__*/ React__default['default'].createElement(
'text',
_extends__default['default'](
{
dx: offset,
dy: 5,
},
wrappedTextProps
),
props[props.labelProp]
)
: /*#__PURE__*/ React__default['default'].createElement(
'g',
_extends__default['default'](
{
transform: `translate(${offset}, 5)`,
},
wrappedTextProps
),
props[props.labelProp]
);
return /*#__PURE__*/ React__default['default'].createElement(
'g',
_extends__default['default']({}, wrappedGProps, {
transform: getTransform(),
direction: props.direction === 'rtl' ? 'rtl' : null,
}),
/*#__PURE__*/ React__default['default'].createElement(
props.shape,
wrappedNodeProps
),
label
);
}
Node.propTypes = {
x: PropTypes__default['default'].number.isRequired,
y: PropTypes__default['default'].number.isRequired,
keyProp: PropTypes__default['default'].string.isRequired,
labelProp: PropTypes__default['default'].string.isRequired,
direction: PropTypes__default['default'].oneOf(['ltr', 'rtl']).isRequired,
shape: PropTypes__default['default'].string.isRequired,
nodeProps: PropTypes__default['default'].object.isRequired,
gProps: PropTypes__default['default'].object.isRequired,
textProps: PropTypes__default['default'].object.isRequired,
};
function Container(props) {
return /*#__PURE__*/ React__default['default'].createElement(
'svg',
_extends__default['default']({}, props.svgProps, {
height: props.height,
width: props.width,
}),
props.children,
/*#__PURE__*/ React__default['default'].createElement(
'g',
{
transform: `translate(${props.margins.left}, ${props.margins.top})`,
},
props.links.map((link) =>
/*#__PURE__*/ React__default['default'].createElement(Link, {
key: link.target.data[props.keyProp],
keyProp: props.keyProp,
pathFunc: props.pathFunc,
source: link.source,
target: link.target,
x1: link.source.x,
x2: link.target.x,
y1: link.source.y,
y2: link.target.y,
pathProps: {
...props.pathProps,
...link.target.data.pathProps,
},
})
),
props.nodes.map((node) =>
/*#__PURE__*/ React__default['default'].createElement(
Node,
_extends__default['default'](
{
key: node.data[props.keyProp],
keyProp: props.keyProp,
labelProp: props.labelProp,
direction: props.direction,
shape: props.nodeShape,
x: node.x,
y: node.y,
},
node.data,
{
nodeProps: {
...props.nodeProps,
...node.data.nodeProps,
},
gProps: {
...props.gProps,
...node.data.gProps,
},
textProps: {
...props.textProps,
...node.data.textProps,
},
}
)
)
)
)
);
}
Container.propTypes = {
children: PropTypes__default['default'].node,
direction: PropTypes__default['default'].oneOf(['ltr', 'rtl']).isRequired,
height: PropTypes__default['default'].number.isRequired,
keyProp: PropTypes__default['default'].string.isRequired,
labelProp: PropTypes__default['default'].string.isRequired,
links: PropTypes__default['default'].array.isRequired,
margins: PropTypes__default['default'].shape({
left: PropTypes__default['default'].number.isRequired,
top: PropTypes__default['default'].number.isRequired,
}).isRequired,
nodes: PropTypes__default['default'].array.isRequired,
nodeClassName: PropTypes__default['default'].string,
nodeShape: PropTypes__default['default'].string.isRequired,
nodeProps: PropTypes__default['default'].object.isRequired,
pathFunc: PropTypes__default['default'].func,
width: PropTypes__default['default'].number.isRequired,
gProps: PropTypes__default['default'].object.isRequired,
pathProps: PropTypes__default['default'].object.isRequired,
svgProps: PropTypes__default['default'].object.isRequired,
textProps: PropTypes__default['default'].object.isRequired,
};
function Animated(props) {
const initialX = props.nodes[0].x;
const initialY = props.nodes[0].y;
const [state, setState] = React.useState({
nodes: props.nodes.map((n) => ({
...n,
x: initialX,
y: initialY,
})),
links: props.links.map((l) => ({
source: {
...l.source,
x: initialX,
y: initialY,
},
target: {
...l.target,
x: initialX,
y: initialY,
},
})),
});
const [animation, setAnimation] = React.useState(null);
React.useEffect(animate, [props.nodes, props.links]);
function animate() {
// Stop previous animation if one is already in progress. We will start the next animation
// from the position we are currently in
clearInterval(animation);
let counter = 0;
// Do as much one-time calculation outside of the animation step, which needs to be fast
const animationContext = getAnimationContext(state, props);
const interval = setInterval(() => {
counter++;
if (counter === props.steps) {
clearInterval(interval);
setState({
nodes: props.nodes,
links: props.links,
});
return;
}
setState(calculateNewState(animationContext, counter / props.steps));
}, props.duration / props.steps);
setAnimation(interval);
return () => clearInterval(animation);
}
function getAnimationContext(initialState, newState) {
// Nodes/links that are in both states need to be moved from the old position to the new one
// Nodes/links only in the initial state are being removed, and should be moved to the position
// of the closest ancestor that still exists, or the new root
// Nodes/links only in the new state are being added, and should be moved from the position of
// the closest ancestor that previously existed, or the old root
// The base determines which node/link the data (like classes and labels) comes from for rendering
// We only run this once at the start of the animation, so optimisation is less important
const addedNodes = newState.nodes
.filter((n1) => initialState.nodes.every((n2) => !areNodesSame(n1, n2)))
.map((n1) => ({
base: n1,
old: getClosestAncestor(n1, newState, initialState),
new: n1,
}));
const changedNodes = newState.nodes
.filter((n1) => initialState.nodes.some((n2) => areNodesSame(n1, n2)))
.map((n1) => ({
base: n1,
old: initialState.nodes.find((n2) => areNodesSame(n1, n2)),
new: n1,
}));
const removedNodes = initialState.nodes
.filter((n1) => newState.nodes.every((n2) => !areNodesSame(n1, n2)))
.map((n1) => ({
base: n1,
old: n1,
new: getClosestAncestor(n1, initialState, newState),
}));
const addedLinks = newState.links
.filter((l1) => initialState.links.every((l2) => !areLinksSame(l1, l2)))
.map((l1) => ({
base: l1,
old: getClosestAncestor(l1.target, newState, initialState),
new: l1,
}));
const changedLinks = newState.links
.filter((l1) => initialState.links.some((l2) => areLinksSame(l1, l2)))
.map((l1) => ({
base: l1,
old: initialState.links.find((l2) => areLinksSame(l1, l2)),
new: l1,
}));
const removedLinks = initialState.links
.filter((l1) => newState.links.every((l2) => !areLinksSame(l1, l2)))
.map((l1) => ({
base: l1,
old: l1,
new: getClosestAncestor(l1.target, initialState, newState),
}));
return {
nodes: changedNodes.concat(addedNodes).concat(removedNodes),
links: changedLinks.concat(addedLinks).concat(removedLinks),
};
}
function getClosestAncestor(node, stateWithNode, stateWithoutNode) {
let oldParent = node;
while (oldParent) {
let newParent = stateWithoutNode.nodes.find((n) =>
areNodesSame(oldParent, n)
);
if (newParent) {
return newParent;
}
oldParent = stateWithNode.nodes.find((n) =>
(props.getChildren(n) || []).some((c) => areNodesSame(oldParent, c))
);
}
return stateWithoutNode.nodes[0];
}
function areNodesSame(a, b) {
return a.data[props.keyProp] === b.data[props.keyProp];
}
function areLinksSame(a, b) {
return (
a.source.data[props.keyProp] === b.source.data[props.keyProp] &&
a.target.data[props.keyProp] === b.target.data[props.keyProp]
);
}
function calculateNewState(animationContext, interval) {
return {
nodes: animationContext.nodes.map((n) =>
calculateNodePosition(n.base, n.old, n.new, interval)
),
links: animationContext.links.map((l) =>
calculateLinkPosition(l.base, l.old, l.new, interval)
),
};
}
function calculateLinkPosition(link, start, end, interval) {
return {
source: {
...link.source,
x: calculateNewValue(
start.source ? start.source.x : start.x,
end.source ? end.source.x : end.x,
interval
),
y: calculateNewValue(
start.source ? start.source.y : start.y,
end.source ? end.source.y : end.y,
interval
),
},
target: {
...link.target,
x: calculateNewValue(
start.target ? start.target.x : start.x,
end.target ? end.target.x : end.x,
interval
),
y: calculateNewValue(
start.target ? start.target.y : start.y,
end.target ? end.target.y : end.y,
interval
),
},
};
}
function calculateNodePosition(node, start, end, interval) {
return {
...node,
x: calculateNewValue(start.x, end.x, interval),
y: calculateNewValue(start.y, end.y, interval),
};
}
function calculateNewValue(start, end, interval) {
return start + (end - start) * props.easing(interval);
}
return /*#__PURE__*/ React__default['default'].createElement(
Container,
_extends__default['default']({}, props, state)
);
}
Animated.propTypes = {
getChildren: PropTypes__default['default'].func.isRequired,
keyProp: PropTypes__default['default'].string.isRequired,
links: PropTypes__default['default'].array.isRequired,
nodes: PropTypes__default['default'].array.isRequired,
duration: PropTypes__default['default'].number.isRequired,
easing: PropTypes__default['default'].func.isRequired,
steps: PropTypes__default['default'].number.isRequired,
};
function AnimatedTree(props) {
return /*#__PURE__*/ React__default['default'].createElement(
Animated,
_extends__default['default'](
{
duration: props.duration,
easing: props.easing,
getChildren: props.getChildren,
direction: props.direction,
height: props.height,
keyProp: props.keyProp,
labelProp: props.labelProp,
nodeShape: props.nodeShape,
nodeProps: props.nodeProps,
pathFunc: props.pathFunc,
steps: props.steps,
width: props.width,
gProps: {
className: 'node',
...props.gProps,
},
pathProps: {
className: 'link',
...props.pathProps,
},
svgProps: props.svgProps,
textProps: props.textProps,
},
getTreeData(props)
),
props.children
);
}
AnimatedTree.propTypes = {
data: PropTypes__default['default'].object.isRequired,
children: PropTypes__default['default'].node,
direction: PropTypes__default['default'].oneOf(['ltr', 'rtl']).isRequired,
duration: PropTypes__default['default'].number.isRequired,
easing: PropTypes__default['default'].func.isRequired,
steps: PropTypes__default['default'].number.isRequired,
height: PropTypes__default['default'].number.isRequired,
width: PropTypes__default['default'].number.isRequired,
keyProp: PropTypes__default['default'].string.isRequired,
labelProp: PropTypes__default['default'].string.isRequired,
getChildren: PropTypes__default['default'].func.isRequired,
margins: PropTypes__default['default'].shape({
bottom: PropTypes__default['default'].number.isRequired,
left: PropTypes__default['default'].number.isRequired,
right: PropTypes__default['default'].number.isRequired,
top: PropTypes__default['default'].number.isRequired,
}),
pathFunc: PropTypes__default['default'].func,
nodeShape: PropTypes__default['default'].oneOf([
'circle',
'image',
'polygon',
'rect',
]).isRequired,
nodeProps: PropTypes__default['default'].object.isRequired,
gProps: PropTypes__default['default'].object.isRequired,
pathProps: PropTypes__default['default'].object.isRequired,
svgProps: PropTypes__default['default'].object.isRequired,
textProps: PropTypes__default['default'].object.isRequired,
};
AnimatedTree.defaultProps = {
direction: 'ltr',
duration: 500,
easing: d3Ease.easeQuadOut,
getChildren: (n) => n.children,
steps: 20,
keyProp: 'name',
labelProp: 'name',
nodeShape: 'circle',
nodeProps: {},
gProps: {},
pathProps: {},
svgProps: {},
textProps: {},
};
function Tree(props) {
return /*#__PURE__*/ React__default['default'].createElement(
Container,
_extends__default['default'](
{
getChildren: props.getChildren,
direction: props.direction,
height: props.height,
keyProp: props.keyProp,
labelProp: props.labelProp,
nodeShape: props.nodeShape,
nodeProps: props.nodeProps,
pathFunc: props.pathFunc,
width: props.width,
gProps: {
className: 'node',
...props.gProps,
},
pathProps: {
className: 'link',
...props.pathProps,
},
svgProps: props.svgProps,
textProps: props.textProps,
},
getTreeData(props)
),
props.children
);
}
Tree.propTypes = {
data: PropTypes__default['default'].object.isRequired,
children: PropTypes__default['default'].node,
direction: PropTypes__default['default'].oneOf(['ltr', 'rtl']).isRequired,
height: PropTypes__default['default'].number.isRequired,
width: PropTypes__default['default'].number.isRequired,
keyProp: PropTypes__default['default'].string.isRequired,
labelProp: PropTypes__default['default'].string.isRequired,
getChildren: PropTypes__default['default'].func.isRequired,
margins: PropTypes__default['default'].shape({
bottom: PropTypes__default['default'].number.isRequired,
left: PropTypes__default['default'].number.isRequired,
right: PropTypes__default['default'].number.isRequired,
top: PropTypes__default['default'].number.isRequired,
}),
pathFunc: PropTypes__default['default'].func,
nodeShape: PropTypes__default['default'].oneOf([
'circle',
'image',
'polygon',
'rect',
]).isRequired,
nodeProps: PropTypes__default['default'].object.isRequired,
gProps: PropTypes__default['default'].object.isRequired,
pathProps: PropTypes__default['default'].object.isRequired,
svgProps: PropTypes__default['default'].object.isRequired,
textProps: PropTypes__default['default'].object.isRequired,
};
Tree.defaultProps = {
direction: 'ltr',
getChildren: (n) => n.children,
keyProp: 'name',
labelProp: 'name',
nodeShape: 'circle',
nodeProps: {},
gProps: {},
pathProps: {},
svgProps: {},
textProps: {},
};
exports.AnimatedTree = AnimatedTree;
exports.Tree = Tree;
Object.defineProperty(exports, '__esModule', { value: true });
});