UNPKG

react-d3-tree

Version:

React component to create interactive D3 tree hierarchies

527 lines (526 loc) 26.5 kB
"use strict"; var __extends = (this && this.__extends) || (function () { var extendStatics = function (d, b) { extendStatics = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; return extendStatics(d, b); }; return function (d, b) { if (typeof b !== "function" && b !== null) throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); extendStatics(d, b); function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; })(); var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); var react_1 = __importDefault(require("react")); var d3_hierarchy_1 = require("d3-hierarchy"); var d3_selection_1 = require("d3-selection"); var d3_zoom_1 = require("d3-zoom"); var lite_1 = require("dequal/lite"); var clone_1 = __importDefault(require("clone")); var uuid_1 = require("uuid"); var TransitionGroupWrapper_js_1 = __importDefault(require("./TransitionGroupWrapper.js")); var index_js_1 = __importDefault(require("../Node/index.js")); var index_js_2 = __importDefault(require("../Link/index.js")); var globalCss_js_1 = __importDefault(require("../globalCss.js")); var Tree = /** @class */ (function (_super) { __extends(Tree, _super); function Tree() { var _this = _super !== null && _super.apply(this, arguments) || this; _this.state = { dataRef: _this.props.data, data: Tree.assignInternalProperties((0, clone_1.default)(_this.props.data)), d3: Tree.calculateD3Geometry(_this.props), isTransitioning: false, isInitialRenderForDataset: true, dataKey: _this.props.dataKey, }; _this.internalState = { targetNode: null, isTransitioning: false, }; _this.svgInstanceRef = "rd3t-svg-".concat((0, uuid_1.v4)()); _this.gInstanceRef = "rd3t-g-".concat((0, uuid_1.v4)()); /** * Finds the node matching `nodeId` and * expands/collapses it, depending on the current state of * its internal `collapsed` property. * `setState` callback receives targetNode and handles * `props.onClick` if defined. */ _this.handleNodeToggle = function (nodeId) { var data = (0, clone_1.default)(_this.state.data); var matches = _this.findNodesById(nodeId, data, []); var targetNodeDatum = matches[0]; if (_this.props.collapsible && !_this.state.isTransitioning) { if (targetNodeDatum.__rd3t.collapsed) { Tree.expandNode(targetNodeDatum); _this.props.shouldCollapseNeighborNodes && _this.collapseNeighborNodes(targetNodeDatum, data); } else { Tree.collapseNode(targetNodeDatum); } if (_this.props.enableLegacyTransitions) { // Lock node toggling while transition takes place. _this.setState({ data: data, isTransitioning: true }); // Await transitionDuration + 10 ms before unlocking node toggling again. setTimeout(function () { return _this.setState({ isTransitioning: false }); }, _this.props.transitionDuration + 10); } else { _this.setState({ data: data }); } _this.internalState.targetNode = targetNodeDatum; } }; _this.handleAddChildrenToNode = function (nodeId, childrenData) { var _a; var data = (0, clone_1.default)(_this.state.data); var matches = _this.findNodesById(nodeId, data, []); if (matches.length > 0) { var targetNodeDatum = matches[0]; var depth_1 = targetNodeDatum.__rd3t.depth; var formattedChildren = (0, clone_1.default)(childrenData).map(function (node) { return Tree.assignInternalProperties([node], depth_1 + 1); }); (_a = targetNodeDatum.children).push.apply(_a, formattedChildren.flat()); _this.setState({ data: data }); } }; /** * Handles the user-defined `onNodeClick` function. */ _this.handleOnNodeClickCb = function (hierarchyPointNode, evt) { var onNodeClick = _this.props.onNodeClick; if (onNodeClick && typeof onNodeClick === 'function') { // Persist the SyntheticEvent for downstream handling by users. evt.persist(); onNodeClick((0, clone_1.default)(hierarchyPointNode), evt); } }; /** * Handles the user-defined `onLinkClick` function. */ _this.handleOnLinkClickCb = function (linkSource, linkTarget, evt) { var onLinkClick = _this.props.onLinkClick; if (onLinkClick && typeof onLinkClick === 'function') { // Persist the SyntheticEvent for downstream handling by users. evt.persist(); onLinkClick((0, clone_1.default)(linkSource), (0, clone_1.default)(linkTarget), evt); } }; /** * Handles the user-defined `onNodeMouseOver` function. */ _this.handleOnNodeMouseOverCb = function (hierarchyPointNode, evt) { var onNodeMouseOver = _this.props.onNodeMouseOver; if (onNodeMouseOver && typeof onNodeMouseOver === 'function') { // Persist the SyntheticEvent for downstream handling by users. evt.persist(); onNodeMouseOver((0, clone_1.default)(hierarchyPointNode), evt); } }; /** * Handles the user-defined `onLinkMouseOver` function. */ _this.handleOnLinkMouseOverCb = function (linkSource, linkTarget, evt) { var onLinkMouseOver = _this.props.onLinkMouseOver; if (onLinkMouseOver && typeof onLinkMouseOver === 'function') { // Persist the SyntheticEvent for downstream handling by users. evt.persist(); onLinkMouseOver((0, clone_1.default)(linkSource), (0, clone_1.default)(linkTarget), evt); } }; /** * Handles the user-defined `onNodeMouseOut` function. */ _this.handleOnNodeMouseOutCb = function (hierarchyPointNode, evt) { var onNodeMouseOut = _this.props.onNodeMouseOut; if (onNodeMouseOut && typeof onNodeMouseOut === 'function') { // Persist the SyntheticEvent for downstream handling by users. evt.persist(); onNodeMouseOut((0, clone_1.default)(hierarchyPointNode), evt); } }; /** * Handles the user-defined `onLinkMouseOut` function. */ _this.handleOnLinkMouseOutCb = function (linkSource, linkTarget, evt) { var onLinkMouseOut = _this.props.onLinkMouseOut; if (onLinkMouseOut && typeof onLinkMouseOut === 'function') { // Persist the SyntheticEvent for downstream handling by users. evt.persist(); onLinkMouseOut((0, clone_1.default)(linkSource), (0, clone_1.default)(linkTarget), evt); } }; /** * Takes a hierarchy point node and centers the node on the screen * if the dimensions parameter is passed to `Tree`. * * This code is adapted from Rob Schmuecker's centerNode method. * Link: http://bl.ocks.org/robschmuecker/7880033 */ _this.centerNode = function (hierarchyPointNode) { var _a = _this.props, dimensions = _a.dimensions, orientation = _a.orientation, zoom = _a.zoom, centeringTransitionDuration = _a.centeringTransitionDuration; if (dimensions) { var g = (0, d3_selection_1.select)(".".concat(_this.gInstanceRef)); var svg = (0, d3_selection_1.select)(".".concat(_this.svgInstanceRef)); var scale = _this.state.d3.scale; var x = void 0; var y = void 0; // if the orientation is horizontal, calculate the variables inverted (x->y, y->x) if (orientation === 'horizontal') { y = -hierarchyPointNode.x * scale + dimensions.height / 2; x = -hierarchyPointNode.y * scale + dimensions.width / 2; } else { // else, calculate the variables normally (x->x, y->y) x = -hierarchyPointNode.x * scale + dimensions.width / 2; y = -hierarchyPointNode.y * scale + dimensions.height / 2; } //@ts-ignore g.transition() .duration(centeringTransitionDuration) .attr('transform', 'translate(' + x + ',' + y + ')scale(' + scale + ')'); // Sets the viewport to the new center so that it does not jump back to original // coordinates when dragged/zoomed //@ts-ignore svg.call((0, d3_zoom_1.zoom)().transform, d3_zoom_1.zoomIdentity.translate(x, y).scale(zoom)); } }; /** * Determines which additional `className` prop should be passed to the node & returns it. */ _this.getNodeClassName = function (parent, nodeDatum) { var _a = _this.props, rootNodeClassName = _a.rootNodeClassName, branchNodeClassName = _a.branchNodeClassName, leafNodeClassName = _a.leafNodeClassName; var hasParent = parent !== null && parent !== undefined; if (hasParent) { return nodeDatum.children ? branchNodeClassName : leafNodeClassName; } else { return rootNodeClassName; } }; return _this; } Tree.getDerivedStateFromProps = function (nextProps, prevState) { var derivedState = null; // Clone new data & assign internal properties if `data` object reference changed. // If the dataKey was present but didn't change, then we don't need to re-render the tree var dataKeyChanged = !nextProps.dataKey || prevState.dataKey !== nextProps.dataKey; if (nextProps.data !== prevState.dataRef && dataKeyChanged) { derivedState = { dataRef: nextProps.data, data: Tree.assignInternalProperties((0, clone_1.default)(nextProps.data)), isInitialRenderForDataset: true, dataKey: nextProps.dataKey, }; } var d3 = Tree.calculateD3Geometry(nextProps); if (!(0, lite_1.dequal)(d3, prevState.d3)) { derivedState = derivedState || {}; derivedState.d3 = d3; } return derivedState; }; Tree.prototype.componentDidMount = function () { this.bindZoomListener(this.props); this.setState({ isInitialRenderForDataset: false }); }; Tree.prototype.componentDidUpdate = function (prevProps) { if (this.props.data !== prevProps.data) { // If last `render` was due to change in dataset -> mark the initial render as done. this.setState({ isInitialRenderForDataset: false }); } if (!(0, lite_1.dequal)(this.props.translate, prevProps.translate) || !(0, lite_1.dequal)(this.props.scaleExtent, prevProps.scaleExtent) || this.props.zoomable !== prevProps.zoomable || this.props.draggable !== prevProps.draggable || this.props.zoom !== prevProps.zoom || this.props.enableLegacyTransitions !== prevProps.enableLegacyTransitions) { // If zoom-specific props change -> rebind listener with new values. // Or: rebind zoom listeners to new DOM nodes in case legacy transitions were enabled/disabled. this.bindZoomListener(this.props); } if (typeof this.props.onUpdate === 'function') { this.props.onUpdate({ node: this.internalState.targetNode ? (0, clone_1.default)(this.internalState.targetNode) : null, zoom: this.state.d3.scale, translate: this.state.d3.translate, }); } // Reset the last target node after we've flushed it to `onUpdate`. this.internalState.targetNode = null; }; /** * Collapses all tree nodes with a `depth` larger than `initialDepth`. * * @param {array} nodeSet Array of nodes generated by `generateTree` * @param {number} initialDepth Maximum initial depth the tree should render */ Tree.prototype.setInitialTreeDepth = function (nodeSet, initialDepth) { nodeSet.forEach(function (n) { n.data.__rd3t.collapsed = n.depth >= initialDepth; }); }; /** * bindZoomListener - If `props.zoomable`, binds a listener for * "zoom" events to the SVG and sets scaleExtent to min/max * specified in `props.scaleExtent`. */ Tree.prototype.bindZoomListener = function (props) { var _this = this; var zoomable = props.zoomable, scaleExtent = props.scaleExtent, translate = props.translate, zoom = props.zoom, onUpdate = props.onUpdate, hasInteractiveNodes = props.hasInteractiveNodes; var svg = (0, d3_selection_1.select)(".".concat(this.svgInstanceRef)); var g = (0, d3_selection_1.select)(".".concat(this.gInstanceRef)); // Sets initial offset, so that first pan and zoom does not jump back to default [0,0] coords. // @ts-ignore svg.call((0, d3_zoom_1.zoom)().transform, d3_zoom_1.zoomIdentity.translate(translate.x, translate.y).scale(zoom)); svg.call((0, d3_zoom_1.zoom)() .scaleExtent(zoomable ? [scaleExtent.min, scaleExtent.max] : [zoom, zoom]) // TODO: break this out into a separate zoom handler fn, rather than inlining it. .filter(function (event) { if (hasInteractiveNodes) { return (event.target.classList.contains(_this.svgInstanceRef) || event.target.classList.contains(_this.gInstanceRef) || event.shiftKey); } return true; }) .on('zoom', function (event) { if (!_this.props.draggable && ['mousemove', 'touchmove', 'dblclick'].includes(event.sourceEvent.type)) { return; } g.attr('transform', event.transform); if (typeof onUpdate === 'function') { // This callback is magically called not only on "zoom", but on "drag", as well, // even though event.type == "zoom". // Taking advantage of this and not writing a "drag" handler. onUpdate({ node: null, zoom: event.transform.k, translate: { x: event.transform.x, y: event.transform.y }, }); // TODO: remove this? Shouldn't be mutating state keys directly. _this.state.d3.scale = event.transform.k; _this.state.d3.translate = { x: event.transform.x, y: event.transform.y, }; } })); }; /** * Assigns internal properties that are required for tree * manipulation to each node in the `data` set and returns a new `data` array. * * @static */ Tree.assignInternalProperties = function (data, currentDepth) { if (currentDepth === void 0) { currentDepth = 0; } // Wrap the root node into an array for recursive transformations if it wasn't in one already. var d = Array.isArray(data) ? data : [data]; return d.map(function (n) { var nodeDatum = n; nodeDatum.__rd3t = { id: null, depth: null, collapsed: false }; nodeDatum.__rd3t.id = (0, uuid_1.v4)(); // D3@v5 compat: manually assign `depth` to node.data so we don't have // to hold full node+link sets in state. // TODO: avoid this extra step by checking D3's node.depth directly. nodeDatum.__rd3t.depth = currentDepth; // If there are children, recursively assign properties to them too. if (nodeDatum.children && nodeDatum.children.length > 0) { nodeDatum.children = Tree.assignInternalProperties(nodeDatum.children, currentDepth + 1); } return nodeDatum; }); }; /** * Recursively walks the nested `nodeSet` until a node matching `nodeId` is found. */ Tree.prototype.findNodesById = function (nodeId, nodeSet, hits) { var _this = this; if (hits.length > 0) { return hits; } hits = hits.concat(nodeSet.filter(function (node) { return node.__rd3t.id === nodeId; })); nodeSet.forEach(function (node) { if (node.children && node.children.length > 0) { hits = _this.findNodesById(nodeId, node.children, hits); } }); return hits; }; /** * Recursively walks the nested `nodeSet` until all nodes at `depth` have been found. * * @param {number} depth Target depth for which nodes should be returned * @param {array} nodeSet Array of nested `node` objects * @param {array} accumulator Accumulator for matches, passed between recursive calls */ Tree.prototype.findNodesAtDepth = function (depth, nodeSet, accumulator) { var _this = this; accumulator = accumulator.concat(nodeSet.filter(function (node) { return node.__rd3t.depth === depth; })); nodeSet.forEach(function (node) { if (node.children && node.children.length > 0) { accumulator = _this.findNodesAtDepth(depth, node.children, accumulator); } }); return accumulator; }; /** * Recursively sets the internal `collapsed` property of * the passed `TreeNodeDatum` and its children to `true`. * * @static */ Tree.collapseNode = function (nodeDatum) { nodeDatum.__rd3t.collapsed = true; if (nodeDatum.children && nodeDatum.children.length > 0) { nodeDatum.children.forEach(function (child) { Tree.collapseNode(child); }); } }; /** * Sets the internal `collapsed` property of * the passed `TreeNodeDatum` object to `false`. * * @static */ Tree.expandNode = function (nodeDatum) { nodeDatum.__rd3t.collapsed = false; }; /** * Collapses all nodes in `nodeSet` that are neighbors (same depth) of `targetNode`. */ Tree.prototype.collapseNeighborNodes = function (targetNode, nodeSet) { var neighbors = this.findNodesAtDepth(targetNode.__rd3t.depth, nodeSet, []).filter(function (node) { return node.__rd3t.id !== targetNode.__rd3t.id; }); neighbors.forEach(function (neighbor) { return Tree.collapseNode(neighbor); }); }; /** * Generates tree elements (`nodes` and `links`) by * grabbing the rootNode from `this.state.data[0]`. * Restricts tree depth to `props.initialDepth` if defined and if this is * the initial render of the tree. */ Tree.prototype.generateTree = function () { var _a = this.props, initialDepth = _a.initialDepth, depthFactor = _a.depthFactor, separation = _a.separation, nodeSize = _a.nodeSize, orientation = _a.orientation; var isInitialRenderForDataset = this.state.isInitialRenderForDataset; var tree = (0, d3_hierarchy_1.tree)() .nodeSize(orientation === 'horizontal' ? [nodeSize.y, nodeSize.x] : [nodeSize.x, nodeSize.y]) .separation(function (a, b) { return a.parent.data.__rd3t.id === b.parent.data.__rd3t.id ? separation.siblings : separation.nonSiblings; }); var rootNode = tree((0, d3_hierarchy_1.hierarchy)(this.state.data[0], function (d) { return (d.__rd3t.collapsed ? null : d.children); })); var nodes = rootNode.descendants(); var links = rootNode.links(); // Configure nodes' `collapsed` property on first render if `initialDepth` is defined. if (initialDepth !== undefined && isInitialRenderForDataset) { this.setInitialTreeDepth(nodes, initialDepth); } if (depthFactor) { nodes.forEach(function (node) { node.y = node.depth * depthFactor; }); } return { nodes: nodes, links: links }; }; /** * Set initial zoom and position. * Also limit zoom level according to `scaleExtent` on initial display. This is necessary, * because the first time we are setting it as an SVG property, instead of going * through D3's scaling mechanism, which would have picked up both properties. * * @static */ Tree.calculateD3Geometry = function (nextProps) { var scale; if (nextProps.zoom > nextProps.scaleExtent.max) { scale = nextProps.scaleExtent.max; } else if (nextProps.zoom < nextProps.scaleExtent.min) { scale = nextProps.scaleExtent.min; } else { scale = nextProps.zoom; } return { translate: nextProps.translate, scale: scale, }; }; Tree.prototype.render = function () { var _this = this; var _a = this.generateTree(), nodes = _a.nodes, links = _a.links; var _b = this.props, renderCustomNodeElement = _b.renderCustomNodeElement, orientation = _b.orientation, pathFunc = _b.pathFunc, transitionDuration = _b.transitionDuration, nodeSize = _b.nodeSize, depthFactor = _b.depthFactor, initialDepth = _b.initialDepth, separation = _b.separation, enableLegacyTransitions = _b.enableLegacyTransitions, svgClassName = _b.svgClassName, pathClassFunc = _b.pathClassFunc; var _c = this.state.d3, translate = _c.translate, scale = _c.scale; var subscriptions = __assign(__assign(__assign({}, nodeSize), separation), { depthFactor: depthFactor, initialDepth: initialDepth }); return (react_1.default.createElement("div", { className: "rd3t-tree-container rd3t-grabbable" }, react_1.default.createElement("style", null, globalCss_js_1.default), react_1.default.createElement("svg", { className: "rd3t-svg ".concat(this.svgInstanceRef, " ").concat(svgClassName), width: "100%", height: "100%" }, react_1.default.createElement(TransitionGroupWrapper_js_1.default, { enableLegacyTransitions: enableLegacyTransitions, component: "g", className: "rd3t-g ".concat(this.gInstanceRef), transform: "translate(".concat(translate.x, ",").concat(translate.y, ") scale(").concat(scale, ")") }, links.map(function (linkData, i) { return (react_1.default.createElement(index_js_2.default, { key: 'link-' + i, orientation: orientation, pathFunc: pathFunc, pathClassFunc: pathClassFunc, linkData: linkData, onClick: _this.handleOnLinkClickCb, onMouseOver: _this.handleOnLinkMouseOverCb, onMouseOut: _this.handleOnLinkMouseOutCb, enableLegacyTransitions: enableLegacyTransitions, transitionDuration: transitionDuration })); }), nodes.map(function (hierarchyPointNode, i) { var data = hierarchyPointNode.data, x = hierarchyPointNode.x, y = hierarchyPointNode.y, parent = hierarchyPointNode.parent; return (react_1.default.createElement(index_js_1.default, { key: 'node-' + i, data: data, position: { x: x, y: y }, hierarchyPointNode: hierarchyPointNode, parent: parent, nodeClassName: _this.getNodeClassName(parent, data), renderCustomNodeElement: renderCustomNodeElement, nodeSize: nodeSize, orientation: orientation, enableLegacyTransitions: enableLegacyTransitions, transitionDuration: transitionDuration, onNodeToggle: _this.handleNodeToggle, onNodeClick: _this.handleOnNodeClickCb, onNodeMouseOver: _this.handleOnNodeMouseOverCb, onNodeMouseOut: _this.handleOnNodeMouseOutCb, handleAddChildrenToNode: _this.handleAddChildrenToNode, subscriptions: subscriptions, centerNode: _this.centerNode })); }))))); }; Tree.defaultProps = { onNodeClick: undefined, onNodeMouseOver: undefined, onNodeMouseOut: undefined, onLinkClick: undefined, onLinkMouseOver: undefined, onLinkMouseOut: undefined, onUpdate: undefined, orientation: 'horizontal', translate: { x: 0, y: 0 }, pathFunc: 'diagonal', pathClassFunc: undefined, transitionDuration: 500, depthFactor: undefined, collapsible: true, initialDepth: undefined, zoomable: true, draggable: true, zoom: 1, scaleExtent: { min: 0.1, max: 1 }, nodeSize: { x: 140, y: 140 }, separation: { siblings: 1, nonSiblings: 2 }, shouldCollapseNeighborNodes: false, svgClassName: '', rootNodeClassName: '', branchNodeClassName: '', leafNodeClassName: '', renderCustomNodeElement: undefined, enableLegacyTransitions: false, hasInteractiveNodes: false, dimensions: undefined, centeringTransitionDuration: 800, dataKey: undefined, }; return Tree; }(react_1.default.Component)); exports.default = Tree;