矩形式树状结构图一般可以简称为Treemapping,因为做这张图一般默认就是Rectangular图。Treemapping的各种制作方法网络上已经流行了许久,但是鲜有人在此之上有创作新意的,只是用于处理一些分组数据的展示,对于这种图像我有一些独特的动画效果设想,不知道是否有人会喜欢我的创意。
首先这种图像给我们的感受非常直观,相比于那些古板的条形图,折线图,饼图之类的图像,这种图制作不但很简单,标签也可以直接插入到图像之中,不像其他图像因为表达出的单个数据的图形比较窄,一般都要使用角标,放在图像之外,如下图所示:
以这张图为例,每一个数据块,我们都可以放入3个标签
- 国家/地区名
- 法定假期数量
- 带薪年假数量
不需要什么横竖坐标去找到轴线对应的点和数据,就能一目了然的看到每一个数据块完整的样貌,并能简单的理清其中的大小关系,下面先让我们实现上面这个简单的图像,然后再讲解我于此之上进一步的一些动画创意。
起步
本文所提到的是当前最新的版本d3@7x
,不需要太复杂的构建,和许多包一样npm install d3
安装下来再像下面这样导入
import * as d3 from "d3";
当然也可以像d3官网和他们在npm的README里开头写的那样,直接写进标签里在线引入
<script type="module">
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
...
</script>
这样的方式不要忘了在script上写type="module"
,毕竟esm不是默认行为
参数设定
//只做个demo直接写body上,用的时候应该把这个当参数传入
const body = d3.select("body");
//在atlashxm.cn和www.shijian.cc/jieri随便找的一些数据
const data = [
{
label: "中非共和国", paidVacation: 24, statutoryHoliday: 14 },
{
label: "柬埔寨", paidVacation: 18, statutoryHoliday: 14 },
{
label: "津巴韦布", paidVacation: 22, statutoryHoliday: 14 },
{
label: "巴基斯坦", paidVacation: 14, statutoryHoliday: 10 },
{
label: "刚果(布)", paidVacation: 26, statutoryHoliday: 10 },
{
label: "哥伦比亚", paidVacation: 15, statutoryHoliday: 18 },
{
label: "苏丹", paidVacation: 20, statutoryHoliday: 11 },
{
label: "哈萨克斯坦", paidVacation: 24, statutoryHoliday: 12 },
{
label: "蒙古", paidVacation: 15, statutoryHoliday: 13 },
{
label: "缅甸", paidVacation: 10, statutoryHoliday: 9 },
{
label: "印度尼西亚", paidVacation: 12, statutoryHoliday: 15 },
{
label: "中国香港", paidVacation: 7, statutoryHoliday: 12 },
{
label: "越南", paidVacation: 12, statutoryHoliday: 14 },
{
label: "朝鲜", paidVacation: 14, statutoryHoliday: 18 },
{
label: "中国", paidVacation: 5, statutoryHoliday: 11 }
];
//尺寸设定
const width = 1500;
const height = 920;
//根节点和数据组的设定
const root = d3.stratify().path(d => d.label)(data);
//这个sum决定最终图像数据块的值,现在把这个值设为法定假与年假的和
root.sum(d => d?.paidVacation + d?.statutoryHoliday);
//这个数据组将经过后面内置函数的运算填充上实际的定位与尺寸
const leaves = root.leaves();
root.sort((a, b) => d3.descending(a.value, b.value));//排序一下
//使用d3内置的treemap函数设定好一些基本的结构
d3.treemap()
//tile可以切换为treemapBinary、treemapSquarify、treemapSliceDice、treemapSlice、treemapDice
.tile(d3.treemapSquarify)
.size([width, height])
.paddingInner(1)
.round(true)(root);
//预定义颜色盘,设定4个色彩阶段(可以自由调节),平均分布于一个与数据长度相同的颜色数组中
const color = d3
.scaleLinear()
.domain([0, data.length / 3, data.length / 3, data.length])
.range(["orange", "limegreen", "skyblue", "#DE2910"]);
最后一个色特意换上咱的中国红,根据schemecolor网站给出的我国国旗色彩值,红色部分即为
#DE2910
参数设定好就可以进行下面的效果了,不止是基础的Treemapping,下面所有的方式都会用到这些参数,调整这些参数对后面的效果也都是有效的
无分组的Treemapping
首先就是画基本的svg
标签,所有的东西都应该放到这里面去,通用的样式也应该设置在此处
const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("font-size", 10);
选用10是因为越小的尺寸后面放大时算起来也好算一些1.1就是11,1.2就是12了,这个无所谓的。下一步就是画出数据组:
const node = svg
.selectAll("g")
.data(leaves)
.join("g")
.attr("transform", d => `translate(${d.x0},${d.y0})`);
每个组包含一个矩阵和里面的若干文字,数据组自然是使用g
标签包裹住其中的内容,然后在g
标签上用transform
属性进行定位,leaves
是刚才预定好的数据组,经过了treemap()
函数的计算已经有了定位和大小值,现在只需要代入给transform
属性即可。
下面便开始绘画矩块:
node.append("rect")
.attr("fill", (d, i) => color(i))
.attr("width", d => d.x1 - d.x0)
.attr("height", d => d.y1 - d.y0);
这个fill
值设定颜色很好理解,就是调用参数设定好的预定义颜色盘。高宽要减一下是因为d3没有直接给出大小值,只给出了右下角坐标,所以右下角坐标减去左上角坐标得出大小,设置为高宽即可。
再进行下一步,绘画文字:
const text = node.append("text").attr("x", 3);
text.append("tspan")
.text(d => d.data.label)
.attr("y", "1.2em")
.attr("font-size", "3.5em")
.attr("font-weight", "bold");
text.append("tspan")
.text(d => d.data.statutoryHoliday + "法定假期")
.attr("y", "3.8em")
.attr("x", "5")
.attr("font-size", "2.5em")
.attr("fill-opacity", "0.9");
text.append("tspan")
.text(d => d.data.paidVacation + "带薪年假")
.attr("y", "5.2em")
.attr("x", "5")
.attr("font-size", "2.5em")
.attr("fill-opacity", "0.9");
看着这部分内容似乎有点多,其实反而是最简单的部分,就是设置一些文字样式,在text
中插入3个tspan
,然后进行定位,用x
设定边距,用y
调节文本之间的间距,此外还有文本大小、加粗、透明等一些属性
最后只需要把整个svg加入到body中即可:
div.append(() => svg.node());
就这么简单,要不了几行代码,因为计算都交给了D3,没有什么复杂的部分,只要设定好位置和样式即可。到这里,你就可以看到最上面那张图的效果。
但是,通常使用一些其他数据的情况下,你很可能遇到一个问题:文本溢出于矩形之外。其实这是一个常见的问题,总会有比较小的块,首先在能调节图片大小的情况下,直接增加svg
大小把文本包进去(很多情况下并不方便调这个),其次能缩小文本大小和间距的情况下就压缩这方面,如果再不行,并且感觉溢出的并不大,可以尝试切换tile
调整排列效果,上面代码注释处也提到了。
如果连这些都不行,那只有在设定text
时进行判断,设定一个临界值,低于这个值,就不显示值,只显示标签,如果标签都无法显示就直接显示为“...”或者空白。
下面就开始说一说我设计的这个动态变化的动画效果。
折叠后弹出
首先直接设定一个矩块的最小值,对于这个图像图,我就设定为300
x200
的大小,然后再用动画变大,也就是把rect
绘图部分改成下面这样:
node.append("rect")
.attr("fill", (d, i) => color(i))
.attr("width", 300)
.attr("height", 200)
.transition()
.attr("width", d => d.x1 - d.x0)
.attr("height", d => d.y1 - d.y0)
.duration(1800).ease(d3.easeCircle);
这样每一个块就都有了一个滑动的放大效果。然后,再把g
标签都定位在左上角,因为要在g
标签生成后插入rect
和text
,所以不能一开始就设置动画,否则后面无法append()
标签了,所以要分为两个步骤,首先去掉g
标签的定位设置:
const node = svg
.selectAll("g")
.data(leaves)
.join("g")
其实就是删掉transform
的设置就好了,然后在rect
和text
设置好之后再进行动画定位:
node.transition()
.attr("transform", d => `translate(${d.x0},${d.y0})`)
.delay(500)
.duration(1500)
.ease(d3.easeCircle);
设置好延时和动画时间、方式就可以看到如下效果了:
这就是折叠后弹出的效果,下面再看一下抽屉滑动效果。
抽屉滑动效果
这个部分其实和上面一样,就是只设g
标签的x
,让y
动态变化就是了,不过如果初始状态如果想要一条直线的话,就要text
也跟随伸缩,不然初始化y
值太低或者是0的情况下,文字就会溢出来。这时候给text
设定scaleY()
来实现从压缩到拉升的效果就好了,初始化的时候文本是缩起来的。当然这是下拉,如果是从左往右拉,那就是只设定y
,让x
动态变化,然后设定text
的scaleX()
,区别不大,这里示例还是下拉的效果,先修改g
标签的初始化:
const node = svg
.selectAll("g")
.data(leaves)
.join("g")
.attr("transform", d => `translate(${
d.x0},0)`);
由于text
后面也需要append()
那3行tspan
标签,所以也和g
标签一样要分开初始化,在子标签append()
之后再进行动画,先修改text
的初始化:
const text = node
.append("text")
.attr("x", 3)
.style("transform", "scaleY(0.05)");
然后修改rect
的初始化,每一块都把height
值初始化为10,然后逐步拉升:
node.append("rect")
.attr("fill", (d, i) => color(i))
.attr("width", d => d.x1 - d.x0)
.attr("height", 10)
.transition()
.attr("height", d => d.y1 - d.y0)
.duration(1800)
注意,这时候我们不需要ease(d3.easeCircle)
这个弧线缓动的节奏了,抽屉就正常均速平滑即可,不需要花哨。然后在所有的tspan
设置完毕之后,插入text
的动画,其实非常简单,也是一句话的事情:
text.transition().style("transform", "none").duration(2000);
文本的动画走慢一点,不用太着急,毕竟这部分效果在走到大约80%的时候,就已经可以看了,所以可以稍微拖慢些,也防止文本拉的太快超出了色块。最后就是节点的动画了:
node.transition()
.attr("transform", d => `translate(${d.x0},${d.y0})`)
.delay(500)
.duration(1500)
也是简简单单,设定好最终的数据就行了,我为了录屏delay(500)
了一下,这个延迟是否需要看自身的需求即可。
下面就是最终的效果:
可能有人会有疑问本文的treemapping为什么没有group,这当然不是不好实现或者是偷懒,而是我想以一种比较新的方式表现这种图,它不是非要有group才能做,普通的条形图,饼图折线图那样的数据,也可以依照这样的方式进行展示。没有必要有group才想到这个图,它确实比较好看,很直观,标签摆放也方便,后面交互起来也简明,大小也有很大的调整空间,能忍受各种各样的屏幕比例。希望读者在有制图需求时,考虑一下这种有趣的统计图。
本文写作于2023年4月24日发布于lyrieek的掘金,于2023年7月13日进行修订发布于lyrieek的阿里云开发者社区。