- 发布于
 
《fullstack d3.js》推荐
- Authors
 
- Name
 - 田中原
 
D3.js 介绍与入门
D3.js(全称:Data-Driven Documents)数据驱动文档是一个基于数据驱动 DOM 的 JS 库。
相比EChart、G2...之类的封装好的图标库,D3就像一个Jquery。 封装了很多函数供开发者使用。
为什么总觉得D3难
- 不常用就会忘记。当我们面对一个简单图表时永远首选会使用EChart一类封装好的图表库。
 - D3的案例很多,但是D3的API变化也较多。网上会有很多个人写的案例,根据网友写的案例去学习,经常性会有示例代码无法运行的问题。
 - D3.js的教程很多、但好的教程相对较少。D3的官方教程是以核心概念为主。
 
今天这次分享最主要想推荐一本书: fullstack d3.js。
fullstack d3.js 是我目前觉得最适合入门的教程。整本书是循序渐进的教学方式,并且总结了D3绘图的7个步骤,非常推荐大家完整的阅读一遍。
认识D3的组成
D3现在已经拆成了单独的模块,可以单独引用。
我们可以简单的把d3的模块按照功能做个简单分类
- 获取数据:d3-dsv、d3-fetch
 - 操作数据:d3-array、d3-random、d3-collection
 - 操作DOM:d3-selection
 - 绘制SVG图形:d3-path、d3-polygon、d3-shape
 - 比例尺:d3-scale
 - 处理颜色:d3-color、d3-hsv、d3-interpolate、d3-scale-chromatic
 - 处理时间:d3-time-format、d3-time、d3-timer
 - 动画:d3-interpolate、d3-transition、d3-ease、d3-timer
 - 地图:d3-geo
 - 特定的可视化图形:d3-quadtree、d3-force、d3-hierarchy、d3-brush、d3-chord、d3-axis、d3-voronoi、d3-contour
 - 交互:d3-drag、d3-zoom、d3-dispatch
 
绘制任何图表的步骤
我们每次制作图表时都需要采取一般步骤
获取数据
查看数据结构并声明如何获取我们需要的值
设置图表尺寸
声明图表的参数(宽高之类的)
绘制画布
渲染图表区域
创建比例尺
为图表中的每个数据到物理像素创建比例尺
绘制数据
渲染数据元素
绘制其他部分
绘制坐标轴、标签和图例等等
设置交互
添加事件监听、交互

我们要做一个散点图
这里我们用一年的天气数据的json作为我们的数据来源。 根据天气数据的湿度和露点(结露的温度) 用散点图展示一个这一年每天湿度和露点的关系
Access data 获取数据
获取数据比较简单,d3提供了各种获取数据的函数如d3.json()之类的。
湿度和露点我们要分别做我们X和Y的数据
const dataset = await d3.json('./data/my_weather_data.json')
const xAccessor = (d) => d.dewPoint
const yAccessor = (d) => d.humidity
Create chart dimensions 设置图表尺寸
我们需要定义图表的尺寸。通常,散点图为正方形,X轴的宽度与Y轴的高度相同。
要制作正方形图表,我们希望高度与宽度相同。 我们直接使用窗口的高度或宽度乘以0.9,给窗口留0.1的空白。
// 2. Create chart dimensions
const width = d3.min([window.innerWidth * 0.9, window.innerHeight * 0.9])
为什么一定要明确图表的尺寸? 在Web开发时,我们经常让元素去自适应大小。 在d3做图时明确图表尺寸对我们有更重要的原因
如果是SVG元素自适应的缩放可能会导致不一致
我们需要知道图表的宽度和高度,以便计算比例尺输出
能更好地控制图表元素的大小
wrapper 是整个 SVG 元素,包含轴、数据元素和图例
bounds 位于 wrapper 内,仅包含数据元素
bounds 周围的要留边距为图表的其他元素(轴、图例)分配空间,同时允许图表区域根据可用空间动态调整大小。

// 2. Create chart dimensions
const width = d3.min([window.innerWidth * 0.9, window.innerHeight * 0.9])
let dimensions = {
  width: width,
  height: width,
  margin: {
    top: 10,
    right: 10,
    bottom: 50,
    left: 50,
  },
}
dimensions.boundedWidth = dimensions.width - dimensions.margin.left - dimensions.margin.right
dimensions.boundedHeight = dimensions.height - dimensions.margin.top - dimensions.margin.bottom
Draw canvas 绘制画布
找到一个现有的DOM元素(#wrapper),添加一个<svg>进去
然后我们使用 attr 来设置 <svg> 的尺寸。
Note that these sizes are the size of the "outside" of our plot. Everything we draw next will be within this <svg>.
const wrapper = d3
  .select('#wrapper')
  .append('svg')
  .attr('width', dimensions.width)
  .attr('height', dimensions.height)
在上面,我们创建了一个g元素,使用transform CSS属性将其向右和向下移动,来当我们的边距。
const bounds = wrapper
  .append('g')
  .style('transform', `translate(${dimensions.margin.left}px, ${dimensions.margin.top}px)`)
Create scales 比例尺
在绘制数据之前,我们需要思考如何将数字从数据域转换到像素域。
让我们从X轴开始。我们想根据露点来决定每天的点的水平位置。
为了找到这个位置,我们使用了d3 scale object,它可以帮助我们将数据映射到像素。
让我们创建一个刻度,它将采用露点(温度),并告诉我们一个点需要向右移动多远。
这将是线性标度,因为输入(露点)和输出(像素)将是线性增加的数字。
const xScale = d3.scaleLinear()
比例尺的概念
我们需要告诉我们的比例尺:
- 需要处理哪些输入(域)
 - 我们想要返回的输出(范围)
 
举个简单的例子,假设数据集中的温度范围为 0到100度。在这种情况下,将温度转换为像素很容易:温度为50 度映射到50个像素,因为范围和域都是[0,100]。 但我们的数据和像素输出之间的关系很少如此简单。 比例尺就可以帮我们完成数据的等比转换。比例尺是D3的亮点之一。

确定范围
为了创建比例,, 我们需要选择要处理的最小值和最大值。
D3有一个辅助函数,我们可以在这里使用: d3.extent() 接受两个参数.(extent:范围)。直接获取最大值和最小值
从数据点提取度量值的访问器函数。如果没有 如果指定,则默认为恒等函数d=>d。
- 数组
 - 从数据点提取度量值的访问器函数、默认为恒等函数d=>d。
 
const xScale = d3
  .scaleLinear()
  .domain(d3.extent(dataset, xAccessor))
  .range([0, dimensions.boundedWidth])
这个比例尺生成的结果是[-7.22, 73.83]。我们的x轴最左侧代表-7.22最右代表73.83

虽然能用,但如果第一个和最后一个刻度线是整数,则更容易读取坐标轴。

D3 Scales有一个.nice()方法,该方法将对我们的Scale域进行四舍五入,从而为我们的X轴提供更友好的边界。 我们可以通过查看使用.nice()之前和之后的值来查看.nice()如何修改我们的X刻度的定义域。
不带参数调用.domain()将输出刻度的现有域
console.log(xScale.domain()) // [-7.22, 73.83]
xScale.nice()
console.log(xScale.domain()) // [10, 80]
const xScale = d3
  .scaleLinear()
  .domain(d3.extent(dataset, xAccessor))
  .range([0, dimensions.boundedWidth])
  .nice()
const yScale = d3
  .scaleLinear()
  .domain(d3.extent(dataset, yAccessor))
  .range([dimensions.boundedHeight, 0])
  .nice()
Draw data 绘制数据
重点来了!绘制散点图的我们需要使用<circle>元素。

cx: 圆心x坐标 cy: 圆心y坐标 r: 半径
bounds
  .append('circle')
  .attr('cx', dimensions.boundedWidth / 2)
  .attr('cy', dimensions.boundedHeight / 2)
  .attr('r', 5)
data.forEach((d) => {
  bounds
    .append('circle')
    .attr('cx', xScale(xAccessor(d)))
    .attr('cy', yScale(yAccessor(d)))
    .attr('r', 5)
})

这种画点的方法虽然能跑,但有几个问题
- 嵌套多了,这使我们的代码更难理解。
 - 函数调用两次,我们最终将绘制两组。
 
大家期望的最好的结果肯定是根据数据来渲染<circle>
Data joins
忘掉上边代码哈。
我们用要开始用D3的选择器,选择所有的<circle>元素
const dots = bounds.selectAll('circle')
这一点和Jquery的选择器就不一样了。我们直接执行bounds.selectAll("circle")的时候,画布上还没有任何元素。
这里就需要我们转换到D3的思路上来。
D3选择的选择器,它知道数据对应的哪些元素已经存在。如果我们已经绘制了数据的一部分,该选择器将知道已经绘制了哪些点,以及需要添加哪些点。
我们用.data()方法把数据传递给选择的对象。
const dots = bounds.selectAll('circle').data(dataset)
当我们调用.data()时,我们将所选元素与数据点数组连接在一起。 返回的选择将包含现有元素、需要添加的新元素和需要删除的旧元素
我们将以三种方式查看对选择对象的这些更改: 我们的选择对象被更新以包含现有DOM元素和数据点之间的任何重叠。 添加了一个_enter键,用于列出尚未呈现元素的任何数据点。 添加了_exit键,用于列出已呈现但不在所提供的数据集中的任何数据点。

可以在控制台看一下
let dots = bounds.selectAll('circle')
console.log(dots)
dots = dots.data(dataset)
console.log(dots)
当前选定的DOM元素位于_groups键下。在我们将数据集加入之前,只包含一个空数组。

但是,下一个选择对象看起来不同。我们有两个新键:_enter和_exit,并且我们的_groups数组有一个具有365个元素的数组

看_enter键。如果我们展开数组并查看其中一个值,我们可以看到一个具有数据属性的对象。

如果我们展开__data__,将看到我们的数据点 我们可以看到 _enter 中的每个值都对应于数据集中的一个值. _exit值是一个空数组—如果我们要删除现有元素,我们能在这里看到。
为了对新元素进行操作,我们可以使用enter方法创建一个仅包含这些元素的D3 selection 对象。

为每个数据点附加一个<circle>。我们可以使用.append()方法,D3将为每个数据点创建一个元素。 这里我们也直接给圆设置一下x,y坐标和半径
const dots = bounds
  .selectAll('circle')
  .data(dataset)
  .enter()
  .append('circle')
  .attr('cx', (d) => xScale(xAccessor(d)))
  .attr('cy', (d) => yScale(yAccessor(d)))
  .attr('r', 5)
  .attr('fill', 'cornflowerblue')
Data join exercise 数据连接练习
下面是一个简单的示例,可以更直观地了解数据连接概念。
function drawDots(dataset, color) {
  const dots = bounds.selectAll("circle").data(dataset)
  dots
    .enter().append("circle")
    .attr("cx", d => xScale(xAccessor(d)))
    .attr("cy", d => yScale(yAccessor(d)))
    .attr("r", 5)
    .attr("fill", color)
}
drawDots(dataset.slice(0, 200), "darkgrey")
一秒钟后,让我们使用整个数据集再次调用该函数,这次使用蓝色。
setTimeout(() => {
  drawDots(dataset, "cornflowerblue")
}, 1000)


如果单纯从函数调用来说,第二次调用时应该把所有的圆圈全部设置成了蓝色。但是我们能看到灰色的并没有变蓝。
分析一下:第二次调用时,365个<circle>已经有200个存在了。所以_enter的补分是剩下的165个点,这165个点被设置成了蓝色。
如果我们想要设置所有圆的颜色 D3 selection 有一个merge()方法,该方法将当前选择与另一个选择合并。 在这种情况下,我们可以将新的enter 选择与原始的dots选择组合在一起。然后更新的时候就会更新所有的点。
function drawDots(data, color) {
  const dots = bounds.selectAll('circle').data(dataset)
  dots
    .enter()
    .append('circle')
    .merge(dots) // 合并到一起更新
    .attr('cx', (d) => xScale(xAccessor(d)))
    .attr('cy', (d) => yScale(yAccessor(d)))
    .attr('r', 5)
    .attr('fill', color)
}
.join()
.join() 是一个.enter(), .append(), .merge()...(还有些我们没用到的)的快捷方式
function drawDots(data, color) {
  const dots = bounds.selectAll('circle').data(dataset)
  dots
    .join('circle')
    .attr('cx', (d) => xScale(xAccessor(d)))
    .attr('cy', (d) => yScale(yAccessor(d)))
    .attr('r', 5)
    .attr('fill', color)
}
drawDots(data.slice(0, 200), 'darkgrey')
setTimeout(() => {
  drawDots(data, 'cornflowerblue')
}, 1000)
.join() 函数能让我们更方便的使用D3 但是.enter(), .append(), .merge() 之类的基础方法还是要了解的。
Draw peripherals 绘制次要内容
主要内容绘制完毕了,我们现在要绘制一下坐标轴
我们可以用d3.axisBottom(),用来生成x轴
轴生成器需要知道
- 从
domain获取X刻度 - 从
range获取尺寸 
const xAxisGenerator = d3.axisBottom().scale(xScale)
// const xAxis = bounds.append("g")
// xAxisGenerator(xAxis)
// 这样也可以生效,但是会导致链式调用断掉
const xAxis = bounds
  .append('g')
  .call(xAxisGenerator)
  .style('transform', `translateY(${dimensions.boundedHeight}px)`)
然后我们标注一下x轴是什么
const xAxisLabel = xAxis
  .append('text')
  .attr('x', dimensions.boundedWidth / 2)
  .attr('y', dimensions.margin.bottom - 10)
  .attr('fill', 'black')
  .style('font-size', '1.4em')
  .html('Dew point (°F)')

Y轴类似,但是略微不同 我们可以用ticks设置刻度。
const yAxisGenerator = d3.axisLeft().scale(yScale).ticks(4)
const yAxisLabel = yAxis
  .append('text')
  .attr('x', -dimensions.boundedHeight / 2)
  .attr('y', -dimensions.margin.left + 10)
  .attr('fill', 'black')
  .style('font-size', '1.4em')
  .text('Relative humidity') // // 相对湿度
  .style('transform', 'rotate(-90deg)')
  .style('text-anchor', 'middle')

设置交互
欲知后事如何,请听下回分解
adding a color scale 添加颜色比例尺
散点图最直观的是x,y两个维度,不过我们可以通过颜色或者大小添加更多的维度
我们的数据里有cloudCover数值,我们可以通过添加颜色来显示云量是如何根据湿度和露点变化的。
const colorAccessor = (d) => d.cloudCover
// 刻度还可以将数字转换为颜色—我们只需要将域替换为一系列颜色
const colorScale = d3
  .scaleLinear()
  .domain(d3.extent(dataset, colorAccessor))
  .range(['skyblue', 'darkslategrey'])
// 回到第五步,把颜色给替换掉
const dots = bounds
  .selectAll('circle')
  .data(dataset)
  .enter()
  .append('circle')
  .attr('cx', (d) => xScale(xAccessor(d)))
  .attr('cy', (d) => yScale(yAccessor(d)))
  .attr('r', 4)
  // .attr("fill", "cornflowerblue")
  .attr('fill', (d) => colorScale(colorAccessor(d)))
  .attr('tabindex', '0')
