A tree graph is a very common graph that has a structure of nodes and children. If a node has too many children, we usually hide them and expand them when the parent node is clicked. For beauty and smoothness, we can add unfolding and folding animations.
Building a tree graph with D3 is very simple, just something like:
{
name: '0',
children: [
{
name: '1',
children: [
{
name: '10'
},
{
name: '1- 1'
}
]
}
{
name: '2',
children: [
{
name: '20'
},
{
name: '2- 1'}]}]}Copy the code
Such a data structure, then:
const tree = D3.tree().nodeSize([Node.height, Node.width]) // Create a tree constructor
const data = D3.hierarchy(Data) // Create a tree data structure
let root = tree(data)
Copy the code
Tree is a tree data constructor, which is a function that takes the above data as an argument and returns the root of the tree. The position of the child Node will be calculated according to its position in the tree and the Node size (node. height and node. width), because the default tree direction in D3 is root at bottom, and we need a diagram with root at center left and leaves at left and right, so the width and height parameter positions need to be swapped when setting the size of the child Node). Get the root through the tree data constructor
Let’s assume that before drawing, we have the SVG elements and g elements to hold nodes and wires: $linkGroup and $nodeGroup.
Because of the click to change the tree structure (expand and collapse), we can expect to repeat the drawing, so we write the drawing process as a function that is called when the parent node is initialized and clicked.
function draw() {
// ...
}
Copy the code
Next fill in the contents of this function.
node
Get all nodes first by using the root node descendants method.
let nodes = root.descendants()
Copy the code
Data binding
Next bind the data:
let $nodes = $nodeGroup.selectAll('.node').data(nodes, d= > d.data.name)
Copy the code
It should be noted that in order to prevent the node of a certain data from being reused and then bound to other data twice, resulting in the chaos of animation, we specify the key of element and data binding here, so that when the node is bound to data, if the key exists, only the previous data will be boundname
Members.
Create the element
Using the new version of the JOIN method simplifies the process of creating, updating, and deleting elements:
$nodes
.join(
enter= > {
let $gs = enter.append('g')
// The process of creating rectangles and text is omitted
return $gs
},
update= > update,
exit= > exit.remove()
)
.attr('class'.'node')
Copy the code
positioning
In this way, the g element representing the node is drawn on the screen, but they are still concentrated at the [0, 0] coordinate position. We set the transform property of the G element according to the X and Y members of the node data.
$nodes.attr('transform'.d= > `translate(${d.x}.${d.y}) `)
Copy the code
attachment
Get data for all connections using the root links method:
let links = root.links()
Copy the code
Links is an array of objects containing the starting and ending points of the line: {source, target}[].
Data binding
let $links = $linkGroup.selectAll('.link').data(links, d= > d.target.data.name)
Copy the code
Here we use the name member of the endpoint as the key.
Create the element
$links.join(
enter= > enter.append('path').attr('class'.'link').attr('fill'.'none').attr('stroke'.'gray'),
update= > update,
exit= > exit.remove()
)
Copy the code
Broken line
$links.attr('d'.d= > {
let s = d.source
let t = d.target
let mx = (s.x + t.x) / 2
return `M ${s.x}.${s.y} L ${mx}.${s.y} L ${mx}.${t.y} L ${t.x}.${t.y}`
})
Copy the code
Although D3 has linkVertical and linkHorizontal connection constructors, the lines constructed by them are all curves. Here, we use the curved broken lines in the X axis of two points as the line.
Node click
When you click on certain nodes, you can collapse the child elements, and when you click again, you can expand the child elements. We need to bind the click event handler to the parent element:
$nodes.on('click', handle_node_click)
/ * * *@name Handle node by clicking *@param {Object} Ev events *@param {Object} D data * /
function handle_node_click(ev, d) {
if(d.depth ! = =0) {
if (d.children) {
d._children = d.children
d.children = undefined
draw() // Draw again
} else if (d._children) {
d.children = d._children
draw()
}
}
}
Copy the code
If this node has a children member, then store it in _children and delete the children member, so that D3 considers this node to have no children when processing data. If this node has no children but _children, then the _children member is reassigned to the children member, and the child reappears.
animation
So far, the tree’s children have not been animated, and the easiest way to animate them is to use D3’s Transition method.
node
For nodes, we call:
$nodes.transition().attr('transform'.d= > `translate(${d.x}.${d.y}) `)
Copy the code
But the node does not appear from the parent, but from the root:
The default position of the node before the transform is set is [0, 0].
The starting position
To make the node appear at the parent node, we first memorize the location of the parent node and then move the node to that location before animation. Since this step is not animated, it looks as if the node appears from there.
Saving the parent node is done when the parent node is clicked:
function handle_node_click(ev, d) {
d.sourceX = d.x // Save its original location
d.sourceY = d.y
if(d.depth ! = =0) {
if (d.children) {
d._children = d.children
d.children = undefined
draw()
} else if (d._children) {
for (let a of d._children) {
a.originX = a.parent.x // Save the original location of the parent node
a.originY = a.parent.y
}
d.children = d._children
draw()
}
}
}
Copy the code
At the same time, before the animation of the node, we set the node to the original position of the parent node:
$nodes
.filter(a= >a.originX ! = =undefined&& a.originY ! = =undefined)
.attr('transform'.d= > {
let x, y
if (d.originX) {
x = d.originX
delete d.originX
} else {
x = d.x
}
if (d.originY) {
y = d.originY
delete d.originY
} else {
y = d.y
}
return `translate(${x}.${y}) `
})
Copy the code
Note that you only need to set those that haveoriginX
andoriginY
Otherwise, non-updated nodes will lose animation. Also, delete after a roworiginX
andoriginY
This group of nodes will also be animated when other nodes expand and collapse, which is obviously redundant and incorrect.
In this way, the starting position of the node expansion animation is on the parent node.
End position
The same is true for the fold animation, just move the element to its parent before deleting it.
exit
.transition()
.attr('transform'.d= > `translate(${d.parent.x}.${d.parent.y}) `)
.remove()
Copy the code
attachment
For wired animations, the problem is that the starting position is in the new position of the parent.
The solution is similar to the way to solve the starting position of the node animation, just record the original position of the parent node. This part of the handle_node_click function:
d.sourceX = d.x
d.sourceY = d.y
Copy the code
Then, when drawing the line, before animation, position the starting point of the line at the original position of the parent node:
enter.attr('d'.d= > {
let s = d.source
let origin = `${s.sourceX || s.x}.${s.sourceY || s.y}`
return `M ${origin} L ${origin} L ${origin} L ${origin}`
})
Copy the code
The source code
index.html
<! DOCTYPEhtml>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="Width = device - width, initial - scale = 1.0" />
<title>Document</title>
<style>
body {
overflow: hidden;
padding: 0;
margin: 0;
}
#app {
overflow: hidden;
position: relative;
width: 100vw;
height: 100vh;
}
</style>
<script src="./script.js" defer type="module"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
Copy the code
script.js
import Data from './data.js'
import * as D3 from 'https://cdn.skypack.dev/d3@7'
const Node = {
width: 200.height: 60.background: 'rgb(0, 139, 248)'.r: 8.color: 'white'
}
const CenterBackground = 'orange'
const ParentBackground = 'darkblue'
const TransitionDuration = 1000
/* svg */
const $svg = D3.create('svg')
$svg.attr('width'.'100%')
$svg.attr('height'.'100%')
$svg.attr('viewBox'.'- 500-500 1000 1000)
const $wrap = $svg.append('g')
$svg.call(
D3.zoom().on('zoom'.ev= > {
$wrap.attr('transform', ev.transform)
})
)
const $linkGroup = $wrap.append('g').attr('class'.'link-group')
const $nodeGroup = $wrap.append('g').attr('class'.'node-group')
document.querySelector('#app').appendChild($svg.node())
/* tree */
const tree = D3.tree().nodeSize([Node.height, Node.width])
const data = D3.hierarchy(Data)
/* draw */
/ * * *@name Draw *@param {Boolean} Init first time */
function draw(init = false) {
let root = tree(data)
let nodes = root.descendants()
let left = root.children.filter(a= > / / ^ | 2 (1).test(a.data.name))
let right = root.children.filter(a= > / / ^ (3 | 4).test(a.data.name))
nodes.forEach(a= > ([a.x, a.y] = [a.y, a.x])) // Need to rotate 90 degrees
let leftMiddleOffset = (left[0].y + left[1].y) / 2
left.forEach(a= > {
a.descendants().forEach(b= > {
b.x = -b.x
b.y -= leftMiddleOffset
})
})
let rightMiddleOffset = (right[0].y + right[1].y) / 2
right.forEach(a= > {
a.descendants().forEach(b= > {
b.y -= rightMiddleOffset
})
})
let $nodes = $nodeGroup
.selectAll('.node')
.data(nodes, d= > d.data.name)
.join(
enter= > {
let $gs = enter.append('g')
$gs
.append('rect')
.attr('width', Node.width / 2)
.attr('height', Node.height * 0.66)
.attr('transform'.`translate(${-Node.width / 4}.${-Node.height * 0.33}) `)
.attr('fill'.d= > {
if (d.depth === 0) {
return CenterBackground
} else if (d.children || d._children) {
return ParentBackground
} else {
return Node.background
}
})
.attr('rx', Node.r)
.attr('ry', Node.r)
$gs
.append('text')
.text(d= > d.data.name)
.style('font-size'.'20px')
.attr('fill', Node.color)
.attr('text-anchor'.'middle')
.attr('y', Node.height * 0.16)
return $gs
},
update= > update,
exit= > {
exit
.transition()
.duration(init ? 0 : TransitionDuration)
.attr('opacity'.0)
.attr('transform'.d= > `translate(${d.parent.x}.${d.parent.y}) `)
.remove()
}
)
.attr('class'.'node')
.on('click', handle_node_click)
$nodes
.filter(a= >a.originX ! = =undefined&& a.originY ! = =undefined)
.attr('opacity'.0)
.attr('transform'.d= > {
let x, y
if (d.originX) {
x = d.originX
delete d.originX
} else {
x = d.x
}
if (d.originY) {
y = d.originY
delete d.originY
} else {
y = d.y
}
return `translate(${x}.${y}) `
})
$nodes
.transition()
.duration(init ? 0 : TransitionDuration)
.attr('opacity'.1)
.attr('transform'.d= > `translate(${d.x}.${d.y}) `)
let links = root.links()
$linkGroup
.selectAll('.link')
.data(links, d= > d.target.data.name)
.join(
enter= >
enter
.append('path')
.attr('class'.'link')
.attr('fill'.'none')
.attr('stroke'.'gray')
.attr('d'.d= > {
let s = d.source
let origin = `${s.sourceX || s.x}.${s.sourceY || s.y}`
return `M ${origin} L ${origin} L ${origin} L ${origin}`
}),
update= > update,
exit= >
exit
.transition()
.duration(init ? 0 : TransitionDuration)
.attr('d'.d= > {
let s = d.source
let origin = `${s.x}.${s.y}`
return `M ${origin} L ${origin} L ${origin} L ${origin}`
})
.remove()
)
.transition()
.duration(init ? 0 : TransitionDuration)
.attr('d'.d= > {
let s = d.source
let t = d.target
let mx = (s.x + t.x) / 2
return `M ${s.x}.${s.y} L ${mx}.${s.y} L ${mx}.${t.y} L ${t.x}.${t.y}`})}/ * * *@name Handle node by clicking *@param {Object} Ev events *@param {Object} D data * /
function handle_node_click(ev, d) {
// d.sourceX = d.x
// d.sourceY = d.y
if(d.depth ! = =0) {
if (d.children) {
d._children = d.children
d.children = undefined
draw()
} else if (d._children) {
for (let a of d._children) {
a.originX = a.parent.x
a.originY = a.parent.y
}
d.children = d._children
draw()
}
}
}
draw(true)
Copy the code
data.js
/ * * *@name Data * /
const data = {
name: '0'.children: [{name: '1'.children: [{name: 1-0 ' '
},
{
name: 1-1 ' '
},
{
name: '2'
},
{
name: '1-3'
},
{
name: 1-4 ' '
},
{
name: '1-5'
},
{
name: '1-6'
},
{
name: '1-7'
},
{
name: '1-9'
},
{
name: '1-10'}]}, {name: '2'.children: [{name: '2-0'
},
{
name: '2-1'
},
{
name: '2-2'
},
{
name: '2-3'
},
{
name: '2-4'}]}, {name: '3'.children: [{name: '3-0'
},
{
name: '3-1'
},
{
name: '3-2'}]}, {name: '4'.children: [{name: 4-0 ' '
},
{
name: 4-1 ' '
},
{
name: 4-2 ' '
},
{
name: 4-3 ' '
},
{
name: 4-4 ' '
},
{
name: 4-5 ' '
},
{
name: 4-6 ' '
},
{
name: '4 to 7'}]}]}export default data
Copy the code