Skip to content
This repository was archived by the owner on Aug 5, 2020. It is now read-only.

Commit f5f1e23

Browse files
authored
Improve state transitions (#13)
1 parent b775cbf commit f5f1e23

File tree

1 file changed

+104
-49
lines changed

1 file changed

+104
-49
lines changed

src/charts/tree/tree.js

+104-49
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const defaultOptions = {
1818
collapsed: 'lightsteelblue',
1919
parent: 'white'
2020
},
21-
radius: 5
21+
radius: 7
2222
},
2323
text: {
2424
colors: {
@@ -44,6 +44,7 @@ const defaultOptions = {
4444
heightBetweenNodesCoeff: 2,
4545
widthBetweenNodesCoeff: 1,
4646
transitionDuration: 750,
47+
blinkDuration: 100,
4748
onClickText: () => {},
4849
tooltipOptions: {
4950
disabled: false,
@@ -69,6 +70,7 @@ export default function(DOMNode, options = {}) {
6970
widthBetweenNodesCoeff,
7071
heightBetweenNodesCoeff,
7172
transitionDuration,
73+
blinkDuration,
7274
state,
7375
rootKeyName,
7476
pushMethod,
@@ -119,6 +121,34 @@ export default function(DOMNode, options = {}) {
119121
layout.sort((a, b) => b.name.toLowerCase() < a.name.toLowerCase() ? 1 : -1)
120122
}
121123

124+
// previousNodePositionsById stores node x and y
125+
// as well as hierarchy (id / parentId);
126+
// helps animating transitions
127+
let previousNodePositionsById = {
128+
root: {
129+
id: 'root',
130+
parentId: null,
131+
x: height / 2,
132+
y: 0
133+
}
134+
}
135+
136+
// traverses a map with node positions by going through the chain
137+
// of parent ids; once a parent that matches the given filter is found,
138+
// the parent position gets returned
139+
function findParentNodePosition(nodePositionsById, nodeId, filter) {
140+
let currentPosition = nodePositionsById[nodeId]
141+
while (currentPosition) {
142+
currentPosition = nodePositionsById[currentPosition.parentId]
143+
if (!currentPosition) {
144+
return null
145+
}
146+
if (!filter || filter(currentPosition)) {
147+
return currentPosition
148+
}
149+
}
150+
}
151+
122152
return function renderChart(nextState = tree || state) {
123153
data = !tree ? map2tree(nextState, {key: rootKeyName, pushMethod}) : nextState
124154

@@ -129,18 +159,25 @@ export default function(DOMNode, options = {}) {
129159
let nodeIndex = 0
130160
let maxLabelLength = 0
131161

162+
// nodes are assigned with string ids, which reflect their location
163+
// within the hierarcy; e.g. "root|branch|subBranch|subBranch[0]|property"
164+
// top-level elemnt always has id "root"
132165
visit(data,
133-
node => maxLabelLength = Math.max(node.name.length, maxLabelLength),
134-
node => node.children && node.children.length > 0 ? node.children : null
166+
node => {
167+
maxLabelLength = Math.max(node.name.length, maxLabelLength)
168+
node.id = node.id || 'root'
169+
},
170+
node => node.children && node.children.length > 0 ? node.children.map((c) => {
171+
c.id = `${node.id || ''}|${c.name}`
172+
return c
173+
}) : null
135174
)
136175

137-
data.x0 = height / 2
138-
data.y0 = 0
139176
/*eslint-disable*/
140-
update(data)
177+
update()
141178
/*eslint-enable*/
142179

143-
function update(source) {
180+
function update() {
144181
// path generator for links
145182
const diagonal = d3.svg.diagonal().projection(d => [d.y, d.x])
146183
// set tree dimensions and spacing between branches and nodes
@@ -153,27 +190,39 @@ export default function(DOMNode, options = {}) {
153190

154191
nodes.forEach(node => node.y = node.depth * (maxLabelLength * 7 * widthBetweenNodesCoeff))
155192

193+
const nodePositions = nodes.map(n => ({
194+
parentId: n.parent && n.parent.id,
195+
id: n.id,
196+
x: n.x,
197+
y: n.y
198+
}))
199+
const nodePositionsById = {}
200+
nodePositions.forEach(node => nodePositionsById[node.id] = node)
201+
156202
// process the node selection
157203
let node = vis.selectAll('g.node')
158204
.property('__oldData__', d => d)
159205
.data(nodes, d => d.id || (d.id = ++nodeIndex))
160-
161206
let nodeEnter = node.enter().append('g')
162207
.attr({
163208
'class': 'node',
164-
transform: d => `translate(${source.y0},${source.x0})`
209+
transform: d => {
210+
const position = findParentNodePosition(nodePositionsById, d.id, (n) => previousNodePositionsById[n.id])
211+
const previousPosition = position && previousNodePositionsById[position.id] || previousNodePositionsById.root
212+
return `translate(${previousPosition.y},${previousPosition.x})`
213+
}
165214
})
166215
.style({
167216
fill: style.text.colors.default,
168217
cursor: 'pointer'
169218
})
170219
.on({
171-
mouseover: function mouseover(d, i) {
220+
mouseover: function mouseover() {
172221
d3.select(this).style({
173222
fill: style.text.colors.hover
174223
})
175224
},
176-
mouseout: function mouseout(d, i) {
225+
mouseout: function mouseout() {
177226
d3.select(this).style({
178227
fill: style.text.colors.default
179228
})
@@ -187,20 +236,27 @@ export default function(DOMNode, options = {}) {
187236
)
188237
}
189238

190-
nodeEnter.append('circle')
239+
// g inside node contains circle and text
240+
// this extra wrapper helps run d3 transitions in parallel
241+
const nodeEnterInnerGroup = nodeEnter.append('g')
242+
nodeEnterInnerGroup.append('circle')
191243
.attr({
192-
'class': 'nodeCircle'
244+
'class': 'nodeCircle',
245+
r: 0
193246
})
194247
.on({
195248
click: clickedNode => {
196249
if (d3.event.defaultPrevented) return
197-
update(toggleChildren(clickedNode))
250+
toggleChildren(clickedNode)
251+
update()
198252
}
199253
})
200254

201-
nodeEnter.append('text')
255+
nodeEnterInnerGroup.append('text')
202256
.attr({
203257
'class': 'nodeText',
258+
'text-anchor': 'middle',
259+
'transform': `translate(0,0)`,
204260
dy: '.35em'
205261
})
206262
.style({
@@ -213,17 +269,10 @@ export default function(DOMNode, options = {}) {
213269

214270
// update the text to reflect whether node has children or not
215271
node.select('text')
216-
.attr({
217-
x: d => d.children || d._children ? -(style.node.radius + 10) : style.node.radius + 10,
218-
'text-anchor': d => d.children || d._children ? 'end' : 'start'
219-
})
220272
.text(d => d.name)
221273

222274
// change the circle fill depending on whether it has children and is collapsed
223-
node.select('circle.nodeCircle')
224-
.attr({
225-
r: style.node.radius
226-
})
275+
node.select('circle')
227276
.style({
228277
stroke: 'black',
229278
'stroke-width': '1.5px',
@@ -237,29 +286,41 @@ export default function(DOMNode, options = {}) {
237286
transform: d => `translate(${d.y},${d.x})`
238287
})
239288

240-
// fade the text in
289+
// ensure circle radius is correct
290+
nodeUpdate.select('circle')
291+
.attr('r', style.node.radius)
292+
293+
// fade the text in and align it
241294
nodeUpdate.select('text')
242295
.style('fill-opacity', 1)
243-
244-
// restore the circle
245-
nodeUpdate.select('circle').attr('r', 7)
296+
.attr({
297+
transform: function transform(d) {
298+
const x = (d.children || d._children ? -1 : 1) * (this.getBBox().width / 2 + style.node.radius + 5)
299+
return `translate(${x},0)`
300+
}
301+
})
246302

247303
// blink updated nodes
248-
nodeUpdate.filter(function flick(d) {
304+
node.filter(function flick(d) {
249305
// test whether the relevant properties of d match
250306
// the equivalent property of the oldData
251307
// also test whether the old data exists,
252308
// to catch the entering elements!
253-
return (!this.__oldData__ || d.value !== this.__oldData__.value)
309+
return (this.__oldData__ && d.value !== this.__oldData__.value)
254310
})
255-
.style('fill-opacity', '0.3').transition()
256-
.duration(100).style('fill-opacity', '1')
311+
.select('g')
312+
.style('opacity', '0.3').transition()
313+
.duration(blinkDuration).style('opacity', '1')
257314

258315
// transition exiting nodes to the parent's new position
259316
let nodeExit = node.exit().transition()
260317
.duration(transitionDuration)
261318
.attr({
262-
transform: d => `translate(${source.y},${source.x})`
319+
transform: d => {
320+
const position = findParentNodePosition(previousNodePositionsById, d.id, (n) => nodePositionsById[n.id])
321+
const futurePosition = position && nodePositionsById[position.id] || nodePositionsById.root
322+
return `translate(${futurePosition.y},${futurePosition.x})`
323+
}
263324
})
264325
.remove()
265326

@@ -278,13 +339,11 @@ export default function(DOMNode, options = {}) {
278339
.attr({
279340
'class': 'link',
280341
d: d => {
281-
let o = {
282-
x: source.x0,
283-
y: source.y0
284-
}
342+
const position = findParentNodePosition(nodePositionsById, d.target.id, (n) => previousNodePositionsById[n.id])
343+
const previousPosition = position && previousNodePositionsById[position.id] || previousNodePositionsById.root
285344
return diagonal({
286-
source: o,
287-
target: o
345+
source: previousPosition,
346+
target: previousPosition
288347
})
289348
}
290349
})
@@ -298,17 +357,16 @@ export default function(DOMNode, options = {}) {
298357
})
299358

300359
// transition exiting nodes to the parent's new position
301-
link.exit().transition()
360+
link.exit()
361+
.transition()
302362
.duration(transitionDuration)
303363
.attr({
304364
d: d => {
305-
let o = {
306-
x: source.x,
307-
y: source.y
308-
}
365+
const position = findParentNodePosition(previousNodePositionsById, d.target.id, (n) => nodePositionsById[n.id])
366+
const futurePosition = position && nodePositionsById[position.id] || nodePositionsById.root
309367
return diagonal({
310-
source: o,
311-
target: o
368+
source: futurePosition,
369+
target: futurePosition
312370
})
313371
}
314372
})
@@ -318,10 +376,7 @@ export default function(DOMNode, options = {}) {
318376
node.property('__oldData__', null)
319377

320378
// stash the old positions for transition
321-
nodes.forEach(d => {
322-
d.x0 = d.x
323-
d.y0 = d.y
324-
})
379+
previousNodePositionsById = nodePositionsById
325380
}
326381
}
327382
}

0 commit comments

Comments
 (0)