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

Improve state transitions #13

Merged
merged 1 commit into from
May 19, 2018
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 104 additions & 49 deletions src/charts/tree/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const defaultOptions = {
collapsed: 'lightsteelblue',
parent: 'white'
},
radius: 5
radius: 7
Copy link
Collaborator Author

@kachkaev kachkaev Aug 19, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The radius from defaultOptions was not used at all, see nodeUpdate.select('circle').attr('r', 7) on old line 245

},
text: {
colors: {
Expand All @@ -44,6 +44,7 @@ const defaultOptions = {
heightBetweenNodesCoeff: 2,
widthBetweenNodesCoeff: 1,
transitionDuration: 750,
blinkDuration: 100,
Copy link
Collaborator Author

@kachkaev kachkaev Aug 19, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value was hardcoded on old line 256

onClickText: () => {},
tooltipOptions: {
disabled: false,
Expand All @@ -69,6 +70,7 @@ export default function(DOMNode, options = {}) {
widthBetweenNodesCoeff,
heightBetweenNodesCoeff,
transitionDuration,
blinkDuration,
state,
rootKeyName,
pushMethod,
Expand Down Expand Up @@ -119,6 +121,34 @@ export default function(DOMNode, options = {}) {
layout.sort((a, b) => b.name.toLowerCase() < a.name.toLowerCase() ? 1 : -1)
}

// previousNodePositionsById stores node x and y
// as well as hierarchy (id / parentId);
// helps animating transitions
let previousNodePositionsById = {
root: {
id: 'root',
parentId: null,
x: height / 2,
y: 0
}
}

// traverses a map with node positions by going through the chain
// of parent ids; once a parent that matches the given filter is found,
// the parent position gets returned
function findParentNodePosition(nodePositionsById, nodeId, filter) {
let currentPosition = nodePositionsById[nodeId]
while (currentPosition) {
currentPosition = nodePositionsById[currentPosition.parentId]
if (!currentPosition) {
return null
}
if (!filter || filter(currentPosition)) {
return currentPosition
}
}
}

return function renderChart(nextState = tree || state) {
data = !tree ? map2tree(nextState, {key: rootKeyName, pushMethod}) : nextState

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

// nodes are assigned with string ids, which reflect their location
// within the hierarcy; e.g. "root|branch|subBranch|subBranch[0]|property"
// top-level elemnt always has id "root"
visit(data,
node => maxLabelLength = Math.max(node.name.length, maxLabelLength),
node => node.children && node.children.length > 0 ? node.children : null
node => {
maxLabelLength = Math.max(node.name.length, maxLabelLength)
node.id = node.id || 'root'
},
node => node.children && node.children.length > 0 ? node.children.map((c) => {
c.id = `${node.id || ''}|${c.name}`
return c
}) : null
)

data.x0 = height / 2
data.y0 = 0
/*eslint-disable*/
update(data)
update()
/*eslint-enable*/

function update(source) {
function update() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to pass source any more, because transition positions are now taken from previousNodePositionsById / nodePositionsById

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

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

const nodePositions = nodes.map(n => ({
parentId: n.parent && n.parent.id,
id: n.id,
x: n.x,
y: n.y
}))
const nodePositionsById = {}
nodePositions.forEach(node => nodePositionsById[node.id] = node)

// process the node selection
let node = vis.selectAll('g.node')
.property('__oldData__', d => d)
.data(nodes, d => d.id || (d.id = ++nodeIndex))

let nodeEnter = node.enter().append('g')
.attr({
'class': 'node',
transform: d => `translate(${source.y0},${source.x0})`
transform: d => {
const position = findParentNodePosition(nodePositionsById, d.id, (n) => previousNodePositionsById[n.id])
const previousPosition = position && previousNodePositionsById[position.id] || previousNodePositionsById.root
return `translate(${previousPosition.y},${previousPosition.x})`
}
})
.style({
fill: style.text.colors.default,
cursor: 'pointer'
})
.on({
mouseover: function mouseover(d, i) {
mouseover: function mouseover() {
d3.select(this).style({
fill: style.text.colors.hover
})
},
mouseout: function mouseout(d, i) {
mouseout: function mouseout() {
d3.select(this).style({
fill: style.text.colors.default
})
Expand All @@ -187,20 +236,27 @@ export default function(DOMNode, options = {}) {
)
}

nodeEnter.append('circle')
// g inside node contains circle and text
// this extra wrapper helps run d3 transitions in parallel
const nodeEnterInnerGroup = nodeEnter.append('g')
nodeEnterInnerGroup.append('circle')
.attr({
'class': 'nodeCircle'
'class': 'nodeCircle',
r: 0
})
.on({
click: clickedNode => {
if (d3.event.defaultPrevented) return
update(toggleChildren(clickedNode))
toggleChildren(clickedNode)
update()
}
})

nodeEnter.append('text')
nodeEnterInnerGroup.append('text')
.attr({
'class': 'nodeText',
'text-anchor': 'middle',
'transform': `translate(0,0)`,
dy: '.35em'
})
.style({
Expand All @@ -213,17 +269,10 @@ export default function(DOMNode, options = {}) {

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

// change the circle fill depending on whether it has children and is collapsed
node.select('circle.nodeCircle')
.attr({
r: style.node.radius
})
node.select('circle')
.style({
stroke: 'black',
'stroke-width': '1.5px',
Expand All @@ -237,29 +286,41 @@ export default function(DOMNode, options = {}) {
transform: d => `translate(${d.y},${d.x})`
})

// fade the text in
// ensure circle radius is correct
nodeUpdate.select('circle')
.attr('r', style.node.radius)

// fade the text in and align it
nodeUpdate.select('text')
.style('fill-opacity', 1)

// restore the circle
nodeUpdate.select('circle').attr('r', 7)
.attr({
transform: function transform(d) {
const x = (d.children || d._children ? -1 : 1) * (this.getBBox().width / 2 + style.node.radius + 5)
return `translate(${x},0)`
}
})

// blink updated nodes
nodeUpdate.filter(function flick(d) {
node.filter(function flick(d) {
// test whether the relevant properties of d match
// the equivalent property of the oldData
// also test whether the old data exists,
// to catch the entering elements!
return (!this.__oldData__ || d.value !== this.__oldData__.value)
return (this.__oldData__ && d.value !== this.__oldData__.value)
})
.style('fill-opacity', '0.3').transition()
.duration(100).style('fill-opacity', '1')
.select('g')
.style('opacity', '0.3').transition()
.duration(blinkDuration).style('opacity', '1')

// transition exiting nodes to the parent's new position
let nodeExit = node.exit().transition()
.duration(transitionDuration)
.attr({
transform: d => `translate(${source.y},${source.x})`
transform: d => {
const position = findParentNodePosition(previousNodePositionsById, d.id, (n) => nodePositionsById[n.id])
const futurePosition = position && nodePositionsById[position.id] || nodePositionsById.root
return `translate(${futurePosition.y},${futurePosition.x})`
}
})
.remove()

Expand All @@ -278,13 +339,11 @@ export default function(DOMNode, options = {}) {
.attr({
'class': 'link',
d: d => {
let o = {
x: source.x0,
y: source.y0
}
const position = findParentNodePosition(nodePositionsById, d.target.id, (n) => previousNodePositionsById[n.id])
const previousPosition = position && previousNodePositionsById[position.id] || previousNodePositionsById.root
return diagonal({
source: o,
target: o
source: previousPosition,
target: previousPosition
})
}
})
Expand All @@ -298,17 +357,16 @@ export default function(DOMNode, options = {}) {
})

// transition exiting nodes to the parent's new position
link.exit().transition()
link.exit()
.transition()
.duration(transitionDuration)
.attr({
d: d => {
let o = {
x: source.x,
y: source.y
}
const position = findParentNodePosition(previousNodePositionsById, d.target.id, (n) => nodePositionsById[n.id])
const futurePosition = position && nodePositionsById[position.id] || nodePositionsById.root
return diagonal({
source: o,
target: o
source: futurePosition,
target: futurePosition
})
}
})
Expand All @@ -318,10 +376,7 @@ export default function(DOMNode, options = {}) {
node.property('__oldData__', null)

// stash the old positions for transition
nodes.forEach(d => {
d.x0 = d.x
d.y0 = d.y
})
previousNodePositionsById = nodePositionsById
}
}
}