Skip to content

Commit f7cb767

Browse files
committed
Merge branch 'feature/04-add-zoom-pan' into develop
2 parents cc6ca5b + 55e8268 commit f7cb767

File tree

3 files changed

+148
-21
lines changed

3 files changed

+148
-21
lines changed

README.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
# React D3 Tree
22
[![Build Status](https://travis-ci.org/bkrem/react-d3-tree.svg?branch=master)](https://travis-ci.org/bkrem/react-d3-tree)
3-
[![Coverage Status](https://coveralls.io/repos/github/bkrem/react-d3-tree/badge.svg?branch=master)](https://coveralls.io/github/bkrem/react-d3-tree?branch=master)
4-
5-
> :construction: :construction:
6-
> This library is work-in-progress, meaning breaking changes/major version bumps are likely until indicated otherwise.
3+
[![Coverage Status](https://coveralls.io/repos/github/bkrem/react-d3-tree/badge.svg?branch=master)](https://coveralls.io/github/bkrem/react-d3-tree?branch=master)
74

85
React D3 Tree is a [React](http://facebook.github.io/react/) component that lets you represent hierarchical data (e.g. ancestor trees, organisational structure, package dependencies) as an animated & interactive tree graph by leveraging [D3](https://d3js.org/)'s `tree` layout.
96

@@ -62,11 +59,13 @@ class MyComponent extends Component {
6259
```
6360

6461
## Props
65-
| Property | Type | Options | Required? | Default | Description |
66-
|----------------|-----------------|-------------------------|-----------|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
67-
| `data` | `array` | | required | `undefined` | Single-element array containing hierarchical object (see `myTreeData` above); contains (at least) `name` and `parent` keys. |
68-
| `orientation` | `string` (enum) | `horizontal` `vertical` | | `horizontal` | `horizontal` - Tree expands left-to-right. `vertical` - Tree expands top-to-bottom |
69-
| `translate` | `object` | | | `{x: 0, y: 0}` | Translates the graph along the x/y axis by the specified amount of pixels (avoids the graph being stuck in the top left canvas corner) |
70-
| `pathFunc` | `string` (enum) | `diagonal` `elbow` | | `diagonal` | `diagonal` - Renders smooth, curved edges between parent-child nodes. `elbow` - Renders sharp edges at right angles between parent-child nodes |
71-
| `collapsible` | `bool` | | | `true` | Sets whether the tree's nodes can be collapsed by clicking them |
72-
| `initialDepth` | `number` | `0` or greater | | `undefined` | Sets the maximum node depth to which the tree is expanded on its initial render; tree renders to full depth if prop is omitted. |
62+
| Property | Type | Options | Required? | Default | Description |
63+
|----------------|-----------------|-------------------------|-----------|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
64+
| `data` | `array` | | required | `undefined` | Single-element array containing hierarchical object (see `myTreeData` above); contains (at least) `name` and `parent` keys. |
65+
| `orientation` | `string` (enum) | `horizontal` `vertical` | | `horizontal` | `horizontal` - Tree expands left-to-right. `vertical` - Tree expands top-to-bottom |
66+
| `translate` | `object` | | | `{x: 0, y: 0}` | Translates the graph along the x/y axis by the specified amount of pixels (avoids the graph being stuck in the top left canvas corner) |
67+
| `pathFunc` | `string` (enum) | `diagonal` `elbow` | | `diagonal` | `diagonal` - Renders smooth, curved edges between parent-child nodes. `elbow` - Renders sharp edges at right angles between parent-child nodes |
68+
| `collapsible` | `bool` | | | `true` | Toggles ability to collapse/expand the tree's nodes by clicking them |
69+
| `initialDepth` | `number` | `0` or greater | | `undefined` | Sets the maximum node depth to which the tree is expanded on its initial render; tree renders to full depth if prop is omitted. |
70+
| `zoomable` | `bool` | | | `true` | Toggles ability to zoom in/out on the Tree by scaling the SVG element according to `props.scaleExtent` |
71+
| `scaleExtent` | `object` | | | `{min: 0.1, max: 1}` | Sets the minimum/maximum extent to which the tree can be scaled if `props.zoomable` is true |

src/Tree/index.js

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { PropTypes } from 'react';
2-
import { layout } from 'd3';
2+
import { layout, behavior, event, select } from 'd3';
33
import clone from 'clone';
44
import uuid from 'uuid';
55

@@ -13,45 +13,102 @@ export default class Tree extends React.Component {
1313
super(props);
1414
this.state = {
1515
initialRender: true,
16-
data: this.assignCustomProperties(clone(this.props.data)),
16+
data: this.assignInternalProperties(clone(this.props.data)),
17+
zoom: undefined,
1718
};
1819
this.findTargetNode = this.findTargetNode.bind(this);
1920
this.collapseNode = this.collapseNode.bind(this);
2021
this.handleNodeToggle = this.handleNodeToggle.bind(this);
2122
}
2223

2324
componentDidMount() {
25+
this.bindZoomListener();
26+
2427
// TODO find better way of setting initialDepth, re-render here is suboptimal
2528
this.setState({ initialRender: false }); // eslint-disable-line
2629
}
2730

2831
componentWillReceiveProps(nextProps) {
32+
// Clone new data & assign internal properties
2933
if (this.props.data !== nextProps.data) {
30-
this.setState({ data: this.assignCustomProperties(clone(nextProps.data)) });
34+
this.setState({
35+
data: this.assignInternalProperties(clone(nextProps.data)),
36+
});
3137
}
3238
}
3339

40+
41+
/**
42+
* setInitialTreeDepth - Description
43+
*
44+
* @param {array} nodeSet Array of nodes generated by `generateTree`
45+
* @param {number} initialDepth Maximum initial depth the tree should render
46+
*
47+
* @return {void}
48+
*/
3449
setInitialTreeDepth(nodeSet, initialDepth) {
35-
console.log('setInitialTreeDepth: ', initialDepth);
3650
nodeSet.forEach((n) => {
3751
n._collapsed = n.depth >= initialDepth;
3852
});
3953
}
4054

41-
assignCustomProperties(data) {
55+
56+
/**
57+
* bindZoomListener - If `props.zoomable`, binds a listener for
58+
* "zoom" events to the SVG and sets scaleExtent to min/max
59+
* specified in `props.scaleExtent`.
60+
*
61+
* @return {void}
62+
*/
63+
bindZoomListener() {
64+
const { zoomable, scaleExtent } = this.props;
65+
const svg = select('.svg');
66+
67+
if (zoomable) {
68+
this.setState({ zoom: 'scale(1)' });
69+
svg.call(behavior.zoom()
70+
.scaleExtent([scaleExtent.min, scaleExtent.max])
71+
.on('zoom', () => {
72+
this.setState({ zoom: `scale(${event.scale})` });
73+
})
74+
);
75+
}
76+
}
77+
78+
79+
/**
80+
* assignInternalProperties - Assigns internal properties to each node in the
81+
* `data` set that are required for tree manipulation and returns
82+
* a new `data` array.
83+
*
84+
* @param {array} data Hierarchical tree data
85+
*
86+
* @return {array} `data` array with internal properties added
87+
*/
88+
assignInternalProperties(data) {
4289
return data.map((node) => {
4390
node.id = uuid.v4();
4491
node._collapsed = false;
4592
// if there are children, recursively assign properties to them too
4693
if (node.children && node.children.length > 0) {
47-
node.children = this.assignCustomProperties(node.children);
94+
node.children = this.assignInternalProperties(node.children);
4895
node._children = node.children;
4996
}
5097
return node;
5198
});
5299
}
53100

54-
// TODO Refactor this into a more readable/reasonable recursive depth-first walk.
101+
102+
/**
103+
* findTargetNode - Recursively walks a set of nodes (`nodeSet`) and its
104+
* children until a `node.id` that matches `nodeId` param is found.
105+
*
106+
* @param {string} nodeId The `node.id` being searched for
107+
* @param {array} nodeSet Array of `node` objects
108+
*
109+
* @return {object} Returns the targeted `node` object
110+
*/
111+
// TODO Refactor this into a more readable/reasonable recursive depth-first walk.
55112
findTargetNode(nodeId, nodeSet) {
56113
const hits = nodeSet.filter((node) => node.id === nodeId);
57114

@@ -68,6 +125,15 @@ export default class Tree extends React.Component {
68125
})[0];
69126
}
70127

128+
129+
/**
130+
* collapseNode - Recursively sets the `_collapsed` property of
131+
* the passed `node` object and its children to `true`.
132+
*
133+
* @param {object} node Node object with custom properties
134+
*
135+
* @return {void}
136+
*/
71137
collapseNode(node) {
72138
node._collapsed = true;
73139
if (node._children && node._children.length > 0) {
@@ -77,10 +143,29 @@ export default class Tree extends React.Component {
77143
}
78144
}
79145

146+
147+
/**
148+
* expandNode - Sets the `_collapsed` property of
149+
* the passed `node` object to `false`.
150+
*
151+
* @param {type} node Node object with custom properties
152+
*
153+
* @return {void}
154+
*/
80155
expandNode(node) {
81156
node._collapsed = false;
82157
}
83158

159+
160+
/**
161+
* handleNodeToggle - Finds the node matching `nodeId` and
162+
* expands/collapses it, depending on the current state of
163+
* its `_collapsed` property.
164+
*
165+
* @param {string} nodeId A node object's `id` field.
166+
*
167+
* @return {void}
168+
*/
84169
handleNodeToggle(nodeId) {
85170
if (this.props.collapsible) {
86171
const data = clone(this.state.data);
@@ -92,6 +177,15 @@ export default class Tree extends React.Component {
92177
}
93178
}
94179

180+
181+
/**
182+
* generateTree - Generates tree elements (`nodes` and `links`) by
183+
* grabbing the rootNode from `this.state.data[0]`.
184+
* Restricts tree depth to `props.initial` if defined and this is
185+
* the initial render of the tree.
186+
*
187+
* @return {object} Object containing `nodes` and `links` fields.
188+
*/
95189
generateTree() {
96190
const { initialDepth } = this.props;
97191
const tree = layout.tree()
@@ -116,8 +210,16 @@ export default class Tree extends React.Component {
116210
const { nodes, links } = this.generateTree();
117211
return (
118212
<div className="treeContainer">
119-
<svg width="100%" height="100%">
120-
<g transform={`translate(${translate.x},${translate.y})`}>
213+
<svg
214+
className="svg"
215+
width="100%"
216+
height="100%"
217+
transform={this.state.zoom}
218+
>
219+
<g
220+
className="gWrapper"
221+
transform={`translate(${translate.x},${translate.y})`}
222+
>
121223

122224
{nodes.map((nodeData) =>
123225
<Node
@@ -153,6 +255,8 @@ Tree.defaultProps = {
153255
pathFunc: 'diagonal',
154256
collapsible: true,
155257
initialDepth: undefined,
258+
zoomable: true,
259+
scaleExtent: { min: 0.1, max: 1 },
156260
};
157261

158262
Tree.propTypes = {
@@ -171,4 +275,9 @@ Tree.propTypes = {
171275
]),
172276
collapsible: PropTypes.bool,
173277
initialDepth: PropTypes.number,
278+
zoomable: PropTypes.bool,
279+
scaleExtent: PropTypes.shape({
280+
min: PropTypes.number,
281+
max: PropTypes.number,
282+
}),
174283
};

src/Tree/tests/index.test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,23 @@ describe('<Tree />', () => {
155155

156156
expect(Tree.prototype.setInitialTreeDepth).toHaveBeenCalled();
157157
});
158+
159+
it('allows zooming in/out according to `props.scaleExtent` if `props.zoomable`', () => {
160+
const zoomableComponent = mount(
161+
<Tree
162+
data={mockData}
163+
/>
164+
);
165+
const nonZoomableComponent = mount(
166+
<Tree
167+
data={mockData}
168+
zoomable={false}
169+
/>
170+
);
171+
172+
zoomableComponent.find('svg').simulate('touchmove');
173+
174+
expect(zoomableComponent.find('svg').prop('transform')).toBeDefined();
175+
expect(nonZoomableComponent.find('svg').prop('transform')).toBeUndefined();
176+
});
158177
});

0 commit comments

Comments
 (0)