0%

D3之一个图表的诞生

注:本文主要是在分享中使用,如果只是看本文的话最好是拿出编辑器跟着我的代码copy也好,对着敲也好,这样才更加了解我在说什么,完整代码在这,文章里面的链接也可以点击去试一试,放心,试出了问题才会想办法去解决~

D3是什么

D3的全称是Data-Driven Document,可见D3是一个基于数据来操作文档的js库。他可以帮助你使用HTML,CSS,SVG甚至是Canvas来展示数据。D3操作SVG的写法像是JQuery,可以对元素添加删除和属性设置,不过D3可以将数据绑定到DOM上,然后根据数据来计算DOM的展示效果,这就是D3的数据驱动。D3不依赖任何库,而且D3不只是一个单独的库,你可以按需加载D3中任意一个单独的模块去使用。

D3能做什么

我们可以看看D3的官方示例库,里面不但可以实现动画,交互,有我们常用的柱图,线图,甚至是一些复杂,另类的图表也可以实现出来。

img

img

其他图表库

说到可视化图表我们最常用的就是Echarts,这个大家都很清楚,上手难度非常低,但是定制化程度也低,往往有想要实现产品的一些特殊的需求,就需要绞尽脑汁的想各种奇淫巧技。

还有就是G2,我没用过,只是大概的去了解了一下,它把图表分成了,图形组件(坐标轴、图例、提示信息等)和几何形状(点、线、面),你可以把数据映射到任意的元素上,然后拼凑成想要的图表。显然自由度就比Echarts高一些,同时上手的难度也要高一些,适合团队根据ui需求封装成自己的图表库。

D3的话就我们可以在接下来内容中来了解了解。

D3的使用步骤

下面张图完美的诠释了使用D3去绘制一个图表的所有步骤。
Chart drawing checklist

  • 先得要有数据,获得json或者csv的数据。
  • 定义图表的尺寸和边距。
  • 定义你要绘制图表的画布。
  • 创建图表的比例尺,设置物理像素到图表数据的比例。
  • 使用对应的图表绘制你的数据。
  • 给图表添加一些额外的元素,坐标轴、图例、提示框等。
  • 最后给你的图表加上相应的交互。

有了上述的步骤我们应该就了解了D3绘制一个图表的过程,接下来我们就一步步的来实现出我的的第一个D3图表。

手把手来做个图

基础架子搭建

首先我们引入D3,在引入了D3后就可以在控制台上输入d3看看我们有没有引入成功,同时可以看见D3下面包含了很多方法。

然后我们创建我们的SVG的元素用来绘制我们的图表,同时给定元素的大小。在script里面定义图表的大小和边距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 初始化代码 -->
<script src="https://lib.baomitu.com/d3/6.7.0/d3.min.js"></script>
<style>
.svg {
border: 1px solid red;
}
</style>
<!-- 创建svg容器 -->
<svg height="500" width="500" class="svg"></svg>
<script>
(async () => {
// 基本容器框架定义
const width = 500
const height = 500
const padding = 50
const innerWidth = width - padding * 2
const innerHeight = height - padding * 2
const xName = '国家'
const yName = '🏅'
})()
</script>

加载数据

架子搭好后我们可以先引入数据看看数据是什么样子的。从这开始我们就正式的接触D3了,数据引入我们使用的是d3-fetch,他提供了一系列加载数据的方法,可以支持你加载txt、csv、json等各种常用的文件类型。你可以单独引入d3-fetch进行使用,不过我们引入了D3就不单独来引入了。

1
2
3
4
5
6
7
8
9
10
// 单独引入的例子
import {csv} from "d3-fetch";
csv(".csv").then((data) => {
console.log(data);
});

import d3 from "d3";
d3.csv("data.csv").then((data) => {
console.log(data); // [{"key": "value"}, …]
});

你可以发现csv方法返回的是一个Promise对象,在D3的v5.x版本采用了Promise来替代之前的异步回调的方式,如果使用的是之前的版本需要注意使用回调函数来获得数据,最好还是使用最新的版本来使用D3。

通过打印我们可以看到数据如下。

1
2
3
4
5
6
7
8
9
10
11
[{
"序号": "1", "国家": "🇺🇸", "🏅": "39",
}, {
"序号": "2", "国家": "🇨🇳", "🏅": "38",
}, {
"序号": "3", "国家": "🇯🇵", "🏅": "27",
}, {
"序号": "4", "国家": "🇬🇧", "🏅": "22",
}, {
"序号": "5", "国家": "🇷🇺", "🏅": "20",
}]

创建比例尺

在正式画图之前我们先要确认画图的比例尺,比例尺(scale)是D3中很重要的一个概念,举个简单的例子我们为什么需要比例尺。y轴一般用来展示数量的数据,我们预计绘制的图形有200px高,然后y轴的数据在0到10这个范围内,所以需要把0-10映射到0-200像素上面,0对应的高度是0像素,5对应的高度是100像素,10对应200。我们通过下面方法创建一个比例尺。

1
2
3
4
5
6
// 使用线性的比例尺
const myScale = d3.scaleLinear()
// 设置需要映射的范围
.domain([0, 10])
// 设置映射后的范围
.range([0, 200])

img

由上图可见我们的映射效果,然后神奇的是创建后的比例尺myScale是一个方法,他接受一个需要映射的值返回映射后的值效果如下

1
2
3
4
5
6
const data = [0,2,3,4,7.5,9,10]
data.forEach(i => {
console.log(myScale(i))
})
// 0 40 60 150 180 200
myScale(20) // 400

我们把数字传入创建的myScale就会返回当前对应的位置,既然是比例尺也不会有范围的限制我们传入大于定义时的数字20得到的是符合比例的400。所以使用比例尺我们就可以很好的控制数据的展示位置和展示大小。

img

上面讲的是非常常用的线性比例尺,D3提供了大约12种不同类型的比例尺,主要分为了四大类。

  • 线性数据映射到线性数据,就是我们刚刚说的,在echarts中数值轴和时间轴就是这一类映射。
  • 线性数据映射到离散数据,比如说scaleQuantize,量化比例尺能够吧0到10的连续数据映射到四个颜色上,10被平均分成四段,每一段内返回的是当前段的颜色。具体效果如下,如果超出两端小于0是返回最左端的颜色,大于10是返回最右端的颜色。

img

img

  • 离散数据映射到线性数据,scaleBand就是接下来做x轴映射的比例尺,我们通常理解为类目轴。效果就从下图就可以看得出来。

img

  • 离散数据映射到离散数据,其实第三种离散到线性的映射,D3帮助你把映射后的线性数据算成了成离散的相同份数,效果上就是离散到离散的映射。

最后回到我们要绘制的图表我们采用scaleLinear作为y轴,scaleBand作为x轴,就很容易的得到下面的代码。

1
2
3
4
5
6
7
// 创建比例尺
const scaleX = d3.scaleBand()
.domain(data.map(i => i[xName]))
.range([0, innerWidth])
const scaleY = d3.scaleLinear()
.domain([0, d3.max(data, i => i[yName])])
.range([0, innerWidth])

创建坐标轴

比例尺创建了后就接下来就可以绘制出坐标轴了。非常简单,通过d3.axisBttom创建x轴,d3.axisLeft创建y轴,传入比例尺就好。

然后我们创建放置坐标轴的容器(这块的用法在下一步进行讲解)然后通过call方法在容器中添加坐标轴。定义坐标轴返回的xAxis是一个方法,参数是挂载的元素,call方法作用是调用xAxis方法同时传入自身作为参数,同等于调用xAxis传入创建的g。这样就可以渲染出坐标轴了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义坐标轴
const xAxis = d3.axisBottom(scaleX)
const yAxis = d3.axisLeft(scaleY)

// 添加柱图容器, 用来放置坐标轴和后面的图形,
const bar = d3.select('svg')
.append('g')
.attr('id', 'bar')
.attr('transform', `translate(${padding}, ${padding})`);

// 把坐标轴添加到元素上, g元素用来分组管理
bar.append('g')
.call(xAxis)
.attr('transform', `translate(0, ${innerHeight})`)
bar.append('g')
.call(yAxis)
// yAxis(bar.append('g'))

聪明的大家肯定会想axisBottom是创建下面的轴,axisLeft是在左边的轴,但是创建的bar元素没有高度怎么能知道下面在哪,其实bottom、left表示的是轴刻度的位置不是坐标轴的位置。是下面,左边。把四个轴放出来就很清楚了。所以x轴需要设置偏移才能展示正确。

然后又发现y轴反过来了,因为默认原点是左上角,所以需要把y轴的映射domain或者range选一个反着传数据就好了。同时可以加上nice让分割更加均匀。

img
img

绘制图表

接下来我们开始我们的重头戏,图表的绘制。首先我们了解一下D3中怎么添加元素。

Selected

可以通过select选择一个元素,selectAll选择多个元素,这两个方法的参数是一个css选择器。就跟jQuery的一样,D3的选择器是可以链式调用的,选择了元素后会返回自身,继续选择或者设置元素的属性。举个例子

1
2
3
4
5
6
7
8
9
d3.selectAll('rect')
.style('fill', 'orange')
.attr('width', 40)
.attr('height', function(d, index) {
return 10 + index * 40;
});

d3.select('rect')
.style('fill', 'pink')

img

我们可以通过attr设置属性,style设置样式,第一个是设置的属性,第二个参数可以是值,也可以是一个方法,在selectAll时选择的多个元素每一个都会执行一次这个方法,传入当前元素的数据和当前选择元素中的index(元素的的数据在后面说明)这样可以设置每一个元素的样式。

我们一般把select后的值称为Selection,打印Selection后发现他有一个_groups,里面就是我们选择到的元素,调用attr方法时会遍历每一个元素去设置属性。

img

selection.append可以添加元素,remove可以删除元素。添加元素后当前的Selection会指向添加后的元素。(demo)

1
2
3
4
5
6
console.log(
d3.selectAll('g')
.append('text')
.text("A")
)
// 打印出group中是新加的text元素

在我学到这里的时候我觉得可以画出柱形图了!先创建放柱子的容器,然后循环数据每次循环为一根柱子,分别设置

  • x,y 比例尺算出的位置
  • width bandwidth是散点比例尺每一项的宽度
  • height 因为y轴反过来了,所以需要用总高度减去比例尺算出的高度
1
2
3
4
5
6
7
8
9
10
const barGroup = bar.append('g')
data.forEach((item) => {
barGroup
.append('rect')
.attr('width', scaleX.bandwidth)
.attr('height', innerHeight - scaleY(item[yName]))
.attr('x', scaleX(item[xName]))
.attr('y', scaleY(item[yName]))
.attr('fill', 'pink')
});

这是你会发现x轴宽度直接撑满了,非常不美观,所以需要在x的比例尺添加padding,0-1的数。

img

img

看起来效果非常的满意,回顾一下难道D3只是帮我操作了SVG的Dom吗?接下来我们来讲讲就是D3的灵魂。

数据驱动

首先是绑定数据,D3可以帮助你把数据绑定到Dom上,通过selected.datum可以绑定一个数据,通过selected.data可以绑定一串数据。之前用循环实现的柱图可以改写一下。或者看例子。可以看见元素的data属性上绑定了对应的数据。

1
2
3
4
5
6
7
8
9
10
11
12
// data绑定实现
data.forEach((item) => {
barGroup.append('rect')
})
barGroup
.selectAll('rect')
.data(data)
.attr('width', scaleX.bandwidth)
.attr('height', d => innerHeight - scaleY(d[yName]))
.attr('x', d => scaleX(d[xName]))
.attr('y', d => scaleY(d[yName]))
.attr('fill', 'pink')

看上面的代码虽然说是把数据绑定到了data上,attr通过function的方式就可以拿到数据,绑定data然后通过function的方式应用数据,这就是D3的数据驱动。但是现在的方式前面得循环append矩形,不太友好。接下来我们介绍一种方式自动管理元素的方法。

Data-join

根据数据渲染元素我们会进行三种操作,如果数据增多会添加(enter)元素,数据减少会删除(exit)元素。如果相等的话元素数量不会更新,只是数据会进行更新(update).

通过data-join可以自动帮我们根据现有元素进行增删改操作,data-join的使用方式如下:

1
2
3
4
5
6
7
8
// 父容器
d3.select(container)
// 1.选择现有需要操作的元素
.selectAll(element)
// 2.传入数据
.data(array)
// 3.根据数据增删指定元素
.join(element);

改写一下我们的线图,只需要在data下面添加join就跟之前的效果一模一样。

1
2
3
4
5
6
7
8
9
10
// datajoin方式
barGroup
.selectAll('rect')
.data(data)
.join('rect')
.attr('width', scaleX.bandwidth)
.attr('height', d => innerHeight - scaleY(d[yName]))
.attr('x', d => scaleX(d[xName]))
.attr('y', d => scaleY(d[yName]))
.attr('fill', 'pink')

在这个例子里面,你可以注释或者添加html里面的circle标签,发现使用了join后最后的circle元素始终就是数据个数。

数据更新

大多数时候数据不是定死的,通常是会跟着时间变化的,在你数据变化的时候data-join并不会监听去改变,所以一般我们会把data-join封装成一个方法,数据变化后去触发。

讲到数据变化就会存在Dom重新渲染的问题,在Vue中我们使用v-for需要加上key防止只修改了一个数据的时候重新修改了整个列表。data-join同样也考虑到了这点,data的第二个参数就接受一个function返回key,在首次渲染的时候肯定是新增元素,在第二次渲染的时候,会通过你的key 方法先检测Dom上之前的数据,然后检测新的数据,通过你的返回值进行匹配,匹配上的就用原来的元素没有就新加(你在key方法里面打log会发现打印出两变,一次是以前的一次是新的)这样有两个小例子可以进去试试 没加key 加了key,在我们的柱图上也可以改一下试试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function update() {
barGroup
.selectAll('rect')
.data(data, (d, i) => {
console.log(d);
return d[xName]
return d['序号']
})
.join('rect')
.attr('width', scaleX.bandwidth)
.attr('height', d => innerHeight - scaleY(d[yName]))
.attr('x', d => scaleX(d[xName]))
.attr('y', d => scaleY(d[yName]))
.attr('fill', 'pink')
}
update()
setTimeout(() => {
data = data_0
update()
}, 3000);

数据渲染我封装成了function,定了三秒钟第二次渲染新的数据,我这里有两个return一个是根据序号一个是更新x值,(第二次的序号我打乱了)按道理key值是使用x值才合理,但是我这里用序号效果也是一样的,原因是x轴位置是通过比例尺就算好了的根x值已经绑定了,所以看不出变化,这是就需要添加数据变化的动画看看Dom到底是怎么去改变的。

添加动画

于是就引出了本小节,动画非常简单,在需要变化的属性设置前加上transition即可。可以移动添加的位置确定在transition之后的操作才有动画。

1
2
3
4
5
// 在上文join后面加上
//.join('rect')
.transition()
// 动画时间,不设置为1s
.duration(2000)

添加交互

最后我们的图表还缺少点灵魂就是交互。D3的Selection提供了一个on的方法,允许监听鼠标键盘的事件,这里就跟写Dom的监听事件差不多了。简单的两个事件就可以实现出hover高亮的效果。

1
2
3
4
5
6
.on('mouseenter', function() {
this.style.fill = 'yellow'
})
.on('mouseleave', function() {
this.style.fill = 'yellowgreen'
})

来给你看几个厉害的

到这里我们的D3基本功能就讲完了一大部分了,用这些功能可以做出很多基本的图形了,不过D3的能量远不仅仅于此。下面简单讲几个复杂点的图形。(主要也是我也在学习中只能简单的带大家过一下)

线图

线图和柱图不同点是,他是线条需要用path路径来绘制,就是SVG中path元素的d属性,他是由直线曲线等各种命令组成的,不过D3提供了一个path路径的生成工具,大家感兴趣可以去了解d3-shape的api,线图他提供了d3.line生成,饼图可以使用d3.arc生成。

1
2
3
4
5
6
7
8
9
const line = d3
.line()
.x(d => scaleX(d[Xkey]))
.y(d => scaleY(d[Ykey]))

svg
.append('path').datum(data)
.attr('stroke', 'green')
.attr('d', line)

img

地图

地图看上去非常难完成,其实最难的就是找到想要的地图数据了,其实就是根据地图的数据绘制出path路径。可以给大家炫耀一下我学了D3后第一个作业

img

力导向图

力导向图用了D3的力布局还有一些作用力,我也还没弄懂就给大家看看例子吧,还有树图也是用了布局相关的知识。

img

Canvas

D3最重要的部分就是data-join数据绑定了,使用SVG很容易把数据绑定到元素上对他进行属性的更改和更新都很容易。而Canvas画布只有像素点是无状态的。我们选择不了画布上的元素进行更新。对于Canvas的局限性,这里提出了一些解决方案。

数据绑定

我们习惯join数据后append对应的元素然后就逐个设置样式,如果是使用Canvas每次更新都需要直接根据全量的数据数据来绘制画布。

1
2
3
data.forEach(function(d) {
context.beginPath()....
})

但是在一些元素量非常大的情况下我们只能用Canvas来渲染,一般的解决方法是使用虚拟Dom来模拟data-join,然后我们就可以进行各种D3的操作。

1
2
const vDOm = d3.select(document.createElement("custom"));
cosnt bars = vDom.selectAll(".bar").data(data).enter()....

这个例子就是在Canvas中模拟data-join的过程。

交互事件

Canvas最主要的问题是无状态,鼠标的交互只能获取在Canvas上的位置,没有元素的概念。不过也有几种方式去模拟点击元素的操作。

  1. 用四叉树来记录所有元素的位置然后根据点击的点去遍历。
  2. 在d3-force的布局中提供了.find方法,可以找到最接近鼠标点击的节点。
  3. 非常巧妙的办法,创建一个一样的画布,每个元素渲染一种颜色,通过点击到的像素点的颜色确定点击到的是那个元素。

ECharts主要也是用Canvas渲染,ECharts 使用的是 ZRender 底层渲染器。ZRender 提供了三种渲染器,分别是 Canvas,SVG 和 VML。下面是原文,总结一下就是根据鼠标位置计算出当前指向的元素。

在判断事件将被哪个图形元素响应的时候,我们会反向循环渲染列表,也就是先判断鼠标是否在位于屏幕前面的图形内。判断的时候,会先将鼠标坐标变换到图形坐标系。这是因为图形可能是经过平移旋转缩放的,甚至图形与图形之间还可能有组合的变换。将鼠标变换到图形坐标系后,我们就可以知道两者的相对关系,然后先根据包围盒做粗略的判断,再根据路径做精确的判断。如果鼠标在该图形内,则让这个图形进行事件分发与冒泡,如果不在,则对位于屏幕后方的元素一一判断。

这里有两种方式的实现demo,SVGCanvas

api支持

D3的大部分模块不是和Dom进行交互的比如说d3-hierarchy用来绘制层级结构的图就只是给出了每个点的位置和每条线的路径。

对于d3-selection、d3-transition模块我们可以通过操作虚拟Dom来转换成Canvas。

对于d3-axis就跟SVG强相关,他会自动帮你构建SVG的坐标轴,这就没办法了。

d3-path来绘制线图饼图地图非常有用,不过D3也允许提供一个context可以帮你绘制在Canvas画布上。demo

一些链接

The Hitchhiker’s Guide to D3.js:原文 翻译

How Selections Work:原文 翻译

D3 in Deoth

我学习过程中的一些demo