| import React, { Component } from 'react'; |
| import PropTypes from 'prop-types'; |
| import { select } from 'd3-selection'; |
| import { tree, stratify } from 'd3-hierarchy'; |
| |
| function diagonal(d) { |
| const offset = 50; |
| return ( |
| 'M' + |
| d.y + |
| ',' + |
| d.x + |
| 'C' + |
| (d.parent.y + offset) + |
| ',' + |
| d.x + |
| ' ' + |
| (d.parent.y + offset) + |
| ',' + |
| d.parent.x + |
| ' ' + |
| d.parent.y + |
| ',' + |
| d.parent.x |
| ); |
| } |
| |
| const nodeRadius = 8; |
| const verticalSpaceBetweenNodes = 70; |
| const NARROW_HORIZONTAL_SPACES = 47; |
| const WIDE_HORIZONTAL_SPACES = 65; |
| |
| const stratifyFn = stratify() |
| .id(d => d.id) |
| .parentId(d => d.parent); |
| |
| class Tree extends Component { |
| // state = { |
| // startingCoordinates: null, |
| // isDown: false |
| // } |
| |
| static propTypes = { |
| name: PropTypes.string, |
| width: PropTypes.number, |
| allowScaleWidth: PropTypes.bool, |
| nodes: PropTypes.arrayOf( |
| PropTypes.shape({ |
| id: PropTypes.string, |
| name: PropTypes.string, |
| parent: PropTypes.string |
| }) |
| ), |
| selectedNodeId: PropTypes.string, |
| onNodeClick: PropTypes.func, |
| onRenderedBeyondWidth: PropTypes.func |
| }; |
| |
| static defaultProps = { |
| width: 500, |
| allowScaleWidth: true, |
| name: 'default-name' |
| }; |
| |
| render() { |
| let { width, name, scrollable = false } = this.props; |
| return ( |
| <div |
| className={`tree-view ${name}-container ${ |
| scrollable ? 'scrollable' : '' |
| }`}> |
| <svg width={width} className={name} /> |
| </div> |
| ); |
| } |
| |
| componentDidMount() { |
| this.renderTree(); |
| } |
| |
| // handleMouseMove(e) { |
| // if (!this.state.isDown) { |
| // return; |
| // } |
| // const container = select(`.tree-view.${this.props.name}-container`); |
| // let coordinates = this.getCoordinates(e); |
| // container.property('scrollLeft' , container.property('scrollLeft') + coordinates.x - this.state.startingCoordinates.x); |
| // container.property('scrollTop' , container.property('scrollTop') + coordinates.y - this.state.startingCoordinates.y); |
| // } |
| |
| // handleMouseDown(e) { |
| // let startingCoordinates = this.getCoordinates(e); |
| // this.setState({ |
| // startingCoordinates, |
| // isDown: true |
| // }); |
| // } |
| |
| // handleMouseUp() { |
| // this.setState({ |
| // startingCorrdinates: null, |
| // isDown: false |
| // }); |
| // } |
| |
| // getCoordinates(e) { |
| // var bounds = e.target.getBoundingClientRect(); |
| // var x = e.clientX - bounds.left; |
| // var y = e.clientY - bounds.top; |
| // return {x, y}; |
| // } |
| |
| componentDidUpdate(prevProps) { |
| if ( |
| this.props.nodes.length !== prevProps.nodes.length || |
| this.props.selectedNodeId !== prevProps.selectedNodeId |
| ) { |
| this.renderTree(); |
| } |
| } |
| |
| renderTree() { |
| let { |
| width, |
| nodes, |
| name, |
| allowScaleWidth, |
| selectedNodeId, |
| onRenderedBeyondWidth, |
| toWiden |
| } = this.props; |
| if (nodes.length > 0) { |
| let horizontalSpaceBetweenLeaves = toWiden |
| ? WIDE_HORIZONTAL_SPACES |
| : NARROW_HORIZONTAL_SPACES; |
| const treeFn = tree().nodeSize([ |
| horizontalSpaceBetweenLeaves, |
| verticalSpaceBetweenNodes |
| ]); //.size([width - 50, height - 50]) |
| let root = stratifyFn(nodes).sort((a, b) => |
| a.data.name.localeCompare(b.data.name) |
| ); |
| let svgHeight = |
| verticalSpaceBetweenNodes * root.height + nodeRadius * 6; |
| |
| treeFn(root); |
| |
| let nodesXValue = root.descendants().map(node => node.x); |
| let maxX = Math.max(...nodesXValue); |
| let minX = Math.min(...nodesXValue); |
| |
| let svgTempWidth = |
| (maxX - minX) / 30 * horizontalSpaceBetweenLeaves; |
| let svgWidth = svgTempWidth < width ? width - 5 : svgTempWidth; |
| const svgEL = select(`svg.${name}`); |
| const container = select(`.tree-view.${name}-container`); |
| svgEL.html(''); |
| svgEL.attr('height', svgHeight); |
| let canvasWidth = width; |
| if (svgTempWidth > width) { |
| if (allowScaleWidth) { |
| canvasWidth = svgTempWidth; |
| } |
| // we seems to have a margin of 25px that we can still see with text |
| if ( |
| svgTempWidth - 25 > width && |
| onRenderedBeyondWidth !== undefined |
| ) { |
| onRenderedBeyondWidth(); |
| } |
| } |
| svgEL.attr('width', canvasWidth); |
| let rootGroup = svgEL |
| .append('g') |
| .attr( |
| 'transform', |
| `translate(${svgWidth / 2 + nodeRadius},${nodeRadius * |
| 4}) rotate(90)` |
| ); |
| |
| // handle link |
| rootGroup |
| .selectAll('.link') |
| .data(root.descendants().slice(1)) |
| .enter() |
| .append('path') |
| .attr('class', 'link') |
| .attr('d', diagonal); |
| |
| let node = rootGroup |
| .selectAll('.node') |
| .data(root.descendants()) |
| .enter() |
| .append('g') |
| .attr( |
| 'class', |
| node => |
| `node ${node.children ? ' has-children' : ' leaf'} ${ |
| node.id === selectedNodeId ? 'selectedNode' : '' |
| } ${this.props.onNodeClick ? 'clickable' : ''}` |
| ) |
| .attr( |
| 'transform', |
| node => 'translate(' + node.y + ',' + node.x + ')' |
| ) |
| .on('click', node => this.onNodeClick(node)); |
| |
| node |
| .append('circle') |
| .attr('r', nodeRadius) |
| .attr('class', 'outer-circle'); |
| node |
| .append('circle') |
| .attr('r', nodeRadius - 3) |
| .attr('class', 'inner-circle'); |
| |
| node |
| .append('text') |
| .attr('y', nodeRadius / 4 + 1) |
| .attr('x', -nodeRadius * 1.8) |
| .text(node => node.data.name) |
| .attr('transform', 'rotate(-90)'); |
| |
| let selectedNode = selectedNodeId |
| ? root.descendants().find(node => node.id === selectedNodeId) |
| : null; |
| if (selectedNode) { |
| container.property( |
| 'scrollLeft', |
| svgWidth / 4 + |
| (svgWidth / 4 - 100) - |
| selectedNode.x / 30 * horizontalSpaceBetweenLeaves |
| ); |
| container.property( |
| 'scrollTop', |
| selectedNode.y / 100 * verticalSpaceBetweenNodes |
| ); |
| } else { |
| container.property( |
| 'scrollLeft', |
| svgWidth / 4 + (svgWidth / 4 - 100) |
| ); |
| } |
| } |
| } |
| |
| onNodeClick(node) { |
| if (this.props.onNodeClick) { |
| this.props.onNodeClick(node.data); |
| } |
| } |
| } |
| |
| export default Tree; |