树图是很常用的图表,它有结点和子节点的结构。如果一个结点的子节点太多,我们通常会把它们先隐藏起来,在点击父节点的时候展开显示。为了美观和流畅,我们可以加上展开收起的动画。
用 D3 构建树图非常简单,只需要类似:
{
name: '0',
children: [
{
name: '1',
children: [
{
name: '1-0'
},
{
name: '1-1'
}
]
}
{
name: '2',
children: [
{
name: '2-0'
},
{
name: '2-1'
}
]
}
]
}
复制代码
这样的数据结构,然后:
const tree = D3.tree().nodeSize([Node.height, Node.width]) // 创建树构造器
const data = D3.hierarchy(Data) // 创建树型数据结构
let root = tree(data)
复制代码
tree
是树型数据构造器,它是一个函数,接受上述的数据作为参数,返回树的根结点。子节点的位置会根据其在树中的位置和节点的大小(Node.height
和Node.width
,因为 D3 中默认树的方向是根在上叶在下,而我们需要的是根在中间左,叶在左右的图表,所以设定子节点大小时宽度和高度的参数位置需要交换)计算好了。通过树型数据构造器得到根结点root
假设在画图前,我们已经准备好了 svg 元素和用于容纳结点和连线的 g 元素:$linkGroup
和$nodeGroup
。
因为有点击改变树结构(展开和收起)的操作,我们可以预见到需要重复绘制,所以我们将绘制这个过程写成一个函数,在初始化和点击父节点时调用它。
function draw() {
// ...
}
复制代码
接下来填充这个函数的内容。
结点
首先通过根结点的descendants
方法获得所有结点。
let nodes = root.descendants()
复制代码
绑定数据
接着绑定数据:
let $nodes = $nodeGroup.selectAll('.node').data(nodes, d => d.data.name)
复制代码
需要注意的是为了防止某个数据的结点被复用,进而二次绑定为其它数据,造成动画的混乱,我们这里指定元素和数据绑定的 key,这样结点在绑定数据时若 key 存在,则只会绑定以前的数据,这里我们使用数据中的name
成员。
创建元素
使用新版本的join
方法可以简化,创建、更新和删除元素的过程:
$nodes
.join(
enter => {
let $gs = enter.append('g')
// 创建矩形和文本的过程省略
return $gs
},
update => update,
exit => exit.remove()
)
.attr('class', 'node')
复制代码
定位
这样,代表结点的 g 元素就绘制到屏幕上了,但此时它们还集中在[0, 0]坐标的位置,我们根据其结点数据中x
和y
成员来设置 g 元素的transform
属性来定位。
$nodes.attr('transform', d => `translate(${d.x}, ${d.y})`)
复制代码
连线
通过根结点的links
方法获取所有连线的数据:
let links = root.links()
复制代码
links
是包含连线的起点和终点的对象的数组:{source, target}[]
。
绑定数据
let $links = $linkGroup.selectAll('.link').data(links, d => d.target.data.name)
复制代码
这里用终点结点的name
成员作为 key。
创建元素
$links.join(
enter => enter.append('path').attr('class', 'link').attr('fill', 'none').attr('stroke', 'gray'),
update => update,
exit => exit.remove()
)
复制代码
折线
$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}`
})
复制代码
虽然 D3 有linkVertical
和linkHorizontal
连线构造器,但是他们构造的连线都是曲线,我们这里采用在两点的 X 轴中点出弯折的折线来作为连线。
结点点击
当点击某些结点时,可以收起其子元素,再次点击时可以展开子元素。我们需要在父元素结点上绑定点击事件处理函数:
$nodes.on('click', handle_node_click)
/**
* @name 处理结点点击
* @param {Object} ev 事件
* @param {Object} d 数据
*/
function handle_node_click(ev, d) {
if (d.depth !== 0) {
if (d.children) {
d._children = d.children
d.children = undefined
draw() // 再次绘制
} else if (d._children) {
d.children = d._children
draw()
}
}
}
复制代码
处理的方式很简单,就是如果这个结点有children
成员,那么就将它暂存到_children
中,并删除children
成员,这样 D3 在处理数据时就认为这个结点没有子结点;如果这个结点没有children
成员但是有_children
,那么就将_children
成员重新赋值给children
成员,子结点就重新出现了。
动画
到目前位置,树图子结点的展开和收起还没有动画,要呈现动画,最简单的方式是使用 D3 的transition
方法。
结点
对于结点,我们在设置位置前调用:
$nodes.transition().attr('transform', d => `translate(${d.x}, ${d.y})`)
复制代码
但是此时结点不是从父节点处出现,而是根结点:
因为结点在未设置transform
前,默认位置是[0, 0]。
起始位置
为了让结点在父节点处出现,我们首先记下父节点的位置,然后在动画前先将结点移动到这个位置。由于这个步骤没有动画,所以看起来就像是结点就从那里出现的。
保存父节点的操作在点击父节点时完成:
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()
}
}
}
复制代码
同时,在结点的动画前,我们设置结点到父节点原本的位置上:
$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})`
})
复制代码
注意这里只需要设置那些有originX
和originY
的结点,否则非更新的结点会失去动画。同时,在一次更行后要删除originX
和originY
,否在其它结点展开收起时,这组结点会也会进行动画,这很明显是多余且错误的。
这样,结点展开动画的起始位置就在父节点上了。
结束位置
对于收起动画也一样,在删除元素前,让元素移动到父元素的位置就行。
exit
.transition()
.attr('transform', d => `translate(${d.parent.x},${d.parent.y})`)
.remove()
复制代码
连线
对于连线的动画,它的问题是起始位置在父结点的新位置上。
解决办法和解决结点动画起始位置的方法类似,记录下父结点原位置即可。即在handle_node_click
函数中的这一部分:
d.sourceX = d.x
d.sourceY = d.y
复制代码
然后在绘制连线时,在动画前将连线的起点定位在父结点原位置即可:
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}`
})
复制代码
源码
index.html
<!DOCTYPE html>
<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>
复制代码
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 绘制
* @param {Boolean} init 第一次
*/
function draw(init = false) {
let root = tree(data)
let nodes = root.descendants()
let left = root.children.filter(a => /^(1|2)/.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])) // 需要旋转90度
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 处理结点点击
* @param {Object} ev 事件
* @param {Object} d 数据
*/
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)
复制代码
data.js
/**
* @name 数据
*/
const data = {
name: '0',
children: [
{
name: '1',
children: [
{
name: '1-0'
},
{
name: '1-1'
},
{
name: '1-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-7'
}
]
}
]
}
export default data
复制代码