D3入门——实现一个柱图

1,482 阅读11分钟

书接上回,上回我们讲了D3的基础知识,这一节我们来使用D3实现一个柱图。

一、基础架子搭建

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

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

<!-- 初始化代码 -->
<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>

1.1 加载数据

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

// 单独引入的例子
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", "国家": "🇺🇸", "🏅": "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。我们通过下面方法创建一个比例尺。

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

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

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。所以使用比例尺我们就可以很好的控制数据的展示位置和展示大小。

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

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

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

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

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

// 创建比例尺
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。这样就可以渲染出坐标轴了。

// 定义坐标轴
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让分割更加均匀。

四、绘制图表

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

4.1 Selected

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

d3.selectAll('rect')
	.style('fill', 'orange')
	.attr('width', 40)
	.attr('height', function(d, index) {
		return 10 + index * 40;
	});

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

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

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

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

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

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

  • x,y 比例尺算出的位置
  • width bandwidth是散点比例尺每一项的宽度
  • height 因为y轴反过来了,所以需要用总高度减去比例尺算出的高度
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的数。

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

4.2 数据驱动

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

// 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矩形,不太友好。接下来我们介绍一种方式自动管理元素的方法。

4.3 Data-join

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

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

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

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

// 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元素始终就是数据个数。

4.4 数据更新

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

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

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之后的操作才有动画。

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

六、添加交互

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

.on('mouseenter', function() {
  this.style.fill = 'yellow'
})
  .on('mouseleave', function() {
  this.style.fill = 'yellowgreen'
})