Skip to content

Commit 904c131

Browse files
sspanakbkrem
authored andcommitted
Zoom control (#78)
* added 'zoom' property to set initial scale * added 'onUpdate' callback prop * added a clarification about 'the missing' onUpdate on drag * fixed a spelling mistake * fixed an incorrect type check * onUpdate() now receives the entire node instead of its ID * updated README.md * scaleExtent constrains zoom level on initial display, not only when zooming with the mouse * fixed inconsistencies when passing values to onUpdate under different circumstances; got rid of double rendering when setting initial tree depth * onUpdate is now dependent only in internal tree state * fixed onUpdate argument discrepancies in unit tests and the tree * removed unnecessary D3 state overwrite
1 parent c2a15eb commit 904c131

File tree

3 files changed

+167
-8
lines changed

3 files changed

+167
-8
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,15 @@ class MyComponent extends React.Component {
8888
| `onClick` | `func` | | | `undefined` | Callback function to be called when a node is clicked. <br /><br /> The clicked node's data object is passed to the callback function. |
8989
| `onMouseOver` | `func` | | | `undefined` | Callback function to be called when mouse enters the space belonging to a node. <br /><br /> The node's data object is passed to the callback. |
9090
| `onMouseOut` | `func` | | | `undefined` | Callback function to be called when mouse leaves the space belonging to a node. <br /><br /> The node's data object is passed to the callback. |
91+
| `onUpdate` | `func` | | | `undefined` | Callback function to be called when the inner D3 component updates. That is - on every zoom or translate event, or when tree branches are toggled. The node's data object, as well as zoom level and coordinates are passed to the callback. |
9192
| `orientation` | `string` (enum) | `horizontal` `vertical` | | `horizontal` | `horizontal` - Tree expands left-to-right. <br /><br /> `vertical` - Tree expands top-to-bottom. |
9293
| `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). |
9394
| `pathFunc` | `string (enum)`/`func` | `diagonal`<br/>`elbow`<br/>`straight`<br/>`customFunc(linkData, orientation)` | | `diagonal` | `diagonal` - Smooth, curved edges between parent-child nodes. <br /><br /> `elbow` - Sharp edges at right angles between parent-child nodes. <br /><br /> `straight` - Straight lines between parent-child nodes. <br /><br /> `customFunc` - Custom draw function that accepts `linkData` as its first param and `orientation` as its second. |
9495
| `collapsible` | `bool` | | | `true` | Toggles ability to collapse/expand the tree's nodes by clicking them. |
9596
| `initialDepth` | `number` | `0..n` | | `undefined` | Sets the maximum node depth to which the tree is expanded on its initial render. <br /> Tree renders to full depth if prop is omitted. |
9697
| `depthFactor` | `number` | `-n..0..n` | | `undefined` | Ensures the tree takes up a fixed amount of space (`node.y = node.depth * depthFactor`), regardless of tree depth. <br /> **TIP**: Negative values invert the tree's direction. |
9798
| `zoomable` | `bool` | | | `true` | Toggles ability to zoom in/out on the Tree by scaling it according to `props.scaleExtent`. |
99+
| `zoom` | `number` | `0..n` | | `1` | A floating point number to set the initial zoom level. It is constrained by `props.scaleExtent`. `1` is the default "non-zoomed" level. |
98100
| `scaleExtent` | `object` | `{min: 0..n, max: 0..n}` | | `{min: 0.1, max: 1}` | Sets the minimum/maximum extent to which the tree can be scaled if `props.zoomable` is true. |
99101
| `nodeSize` | `object` | `{x: 0..n, y: 0..n}` | | `{x: 140, y: 140}` | Sets a fixed size for each node. <br /><br /> This does not affect node circle sizes, circle sizes are handled by the `circleRadius` prop. |
100102
| `separation` | `object` | `{siblings: 0..n, nonSiblings: 0..n}` | | `{siblings: 1, nonSiblings: 2}` | Sets separation between neighbouring nodes, differentiating between siblings (same parent) and non-siblings. |

src/Tree/index.js

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ export default class Tree extends React.Component {
1414
constructor(props) {
1515
super(props);
1616
this.state = {
17-
initialRender: true,
1817
data: this.assignInternalProperties(clone(props.data)),
1918
};
19+
this.internalState = {
20+
initialRender: true,
21+
targetNode: null,
22+
d3: {
23+
scale: this.props.zoom,
24+
translate: this.props.translate,
25+
},
26+
};
2027
this.findNodesById = this.findNodesById.bind(this);
2128
this.collapseNode = this.collapseNode.bind(this);
2229
this.handleNodeToggle = this.handleNodeToggle.bind(this);
@@ -27,8 +34,19 @@ export default class Tree extends React.Component {
2734

2835
componentDidMount() {
2936
this.bindZoomListener(this.props);
30-
// TODO find better way of setting initialDepth, re-render here is suboptimal
31-
this.setState({ initialRender: false }); // eslint-disable-line
37+
this.internalState.initialRender = false;
38+
}
39+
40+
componentDidUpdate() {
41+
if (typeof this.props.onUpdate === 'function') {
42+
this.props.onUpdate({
43+
node: this.internalState.targetNode ? clone(this.internalState.targetNode) : null,
44+
zoom: this.internalState.d3.scale,
45+
translate: this.internalState.d3.translate,
46+
});
47+
48+
this.internalState.targetNode = null;
49+
}
3250
}
3351

3452
componentWillReceiveProps(nextProps) {
@@ -42,7 +60,8 @@ export default class Tree extends React.Component {
4260
// If zoom-specific props change -> rebind listener with new values
4361
if (
4462
!deepEqual(this.props.translate, nextProps.translate) ||
45-
!deepEqual(this.props.scaleExtent, nextProps.scaleExtent)
63+
!deepEqual(this.props.scaleExtent, nextProps.scaleExtent) ||
64+
this.props.zoom !== nextProps.zoom
4665
) {
4766
this.bindZoomListener(nextProps);
4867
}
@@ -70,7 +89,7 @@ export default class Tree extends React.Component {
7089
* @return {void}
7190
*/
7291
bindZoomListener(props) {
73-
const { zoomable, scaleExtent, translate } = props;
92+
const { zoomable, scaleExtent, translate, zoom, onUpdate } = props;
7493
const svg = select('.rd3t-svg');
7594
const g = select('.rd3t-g');
7695

@@ -81,8 +100,21 @@ export default class Tree extends React.Component {
81100
.scaleExtent([scaleExtent.min, scaleExtent.max])
82101
.on('zoom', () => {
83102
g.attr('transform', `translate(${event.translate}) scale(${event.scale})`);
103+
if (typeof onUpdate === 'function') {
104+
// This callback is magically called not only on "zoom", but on "drag", as well,
105+
// even though event.type == "zoom".
106+
// Taking advantage of this and not writing a "drag" handler.
107+
onUpdate({
108+
node: null,
109+
zoom: event.scale,
110+
translate: { x: event.translate[0], y: event.translate[1] },
111+
});
112+
this.internalState.d3.scale = event.scale;
113+
this.internalState.d3.translate = event.translate;
114+
}
84115
})
85116
// Offset so that first pan and zoom does not jump back to [0,0] coords
117+
.scale(zoom)
86118
.translate([translate.x, translate.y]),
87119
);
88120
}
@@ -186,6 +218,7 @@ export default class Tree extends React.Component {
186218
if (this.props.collapsible) {
187219
targetNode._collapsed ? this.expandNode(targetNode) : this.collapseNode(targetNode);
188220
this.setState({ data }, () => this.handleOnClickCb(targetNode));
221+
this.internalState.targetNode = targetNode;
189222
} else {
190223
this.handleOnClickCb(targetNode);
191224
}
@@ -263,7 +296,7 @@ export default class Tree extends React.Component {
263296
const links = tree.links(nodes);
264297

265298
// set `initialDepth` on first render if specified
266-
if (initialDepth !== undefined && this.state.initialRender) {
299+
if (initialDepth !== undefined && this.internalState.initialRender) {
267300
this.setInitialTreeDepth(nodes, initialDepth);
268301
}
269302

@@ -298,13 +331,25 @@ export default class Tree extends React.Component {
298331

299332
const subscriptions = { ...nodeSize, ...separation, depthFactor, initialDepth };
300333

334+
// Limit zoom level according to `scaleExtent` on initial display. This is necessary,
335+
// because the first time we are setting it as an SVG property, instead of going
336+
// through D3's scaling mechanism, which would have picked up both properties.
337+
let scale;
338+
if (this.props.zoom > this.props.scaleExtent.max) {
339+
scale = this.props.scaleExtent.max;
340+
} else if (this.props.zoom < this.props.scaleExtent.min) {
341+
scale = this.props.scaleExtent.min;
342+
} else {
343+
scale = this.props.zoom;
344+
}
345+
301346
return (
302347
<div className={`rd3t-tree-container ${zoomable ? 'rd3t-grabbable' : undefined}`}>
303348
<svg className="rd3t-svg" width="100%" height="100%">
304349
<TransitionGroup
305350
component="g"
306351
className="rd3t-g"
307-
transform={`translate(${translate.x},${translate.y})`}
352+
transform={`translate(${translate.x},${translate.y}) scale(${scale})`}
308353
>
309354
{links.map(linkData => (
310355
<Link
@@ -356,6 +401,7 @@ Tree.defaultProps = {
356401
onClick: undefined,
357402
onMouseOver: undefined,
358403
onMouseOut: undefined,
404+
onUpdate: undefined,
359405
orientation: 'horizontal',
360406
translate: { x: 0, y: 0 },
361407
pathFunc: 'diagonal',
@@ -364,6 +410,7 @@ Tree.defaultProps = {
364410
collapsible: true,
365411
initialDepth: undefined,
366412
zoomable: true,
413+
zoom: 1,
367414
scaleExtent: { min: 0.1, max: 1 },
368415
nodeSize: { x: 140, y: 140 },
369416
separation: { siblings: 1, nonSiblings: 2 },
@@ -388,6 +435,7 @@ Tree.propTypes = {
388435
onClick: PropTypes.func,
389436
onMouseOver: PropTypes.func,
390437
onMouseOut: PropTypes.func,
438+
onUpdate: PropTypes.func,
391439
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
392440
translate: PropTypes.shape({
393441
x: PropTypes.number,
@@ -402,6 +450,7 @@ Tree.propTypes = {
402450
collapsible: PropTypes.bool,
403451
initialDepth: PropTypes.number,
404452
zoomable: PropTypes.bool,
453+
zoom: PropTypes.number,
405454
scaleExtent: PropTypes.shape({
406455
min: PropTypes.number,
407456
max: PropTypes.number,

0 commit comments

Comments
 (0)