自从开始DIY图表,体验和使用图表库完全不同。在这个过程中,你需要思考的问题会比使用图表库多很多,比如:
1、要绘制的图表有哪些部分组成?
2、怎样将数据映射到图形上?
3、交互的逻辑是什么?
这篇文章也主要围绕这几个问题展开。让我们开始吧!
图表有哪些部分组成
图表通常分为 核心图形区域 和 非核心图形区域。核心图形区域是根据数据绘制图形的区域,它承载了图表的主要信息。非核心图形区域为图表提供必要的补充信息,如标题,图例说明,横纵坐标的含义及刻度。
当我们在一块空白区域绘制图表时,首先要做的,就是规划整个图表的大致框架,确定画布的宽高,核心图形区域位于整个画布的什么位置,上下左右边距分别是多少,各种补充信息分别位于哪些位置。
const svgHeight = 320, svgWidth = 700 //svg画布的宽高
const margin = { top: 70, bottom: 40, left: 60, right: 40 } //上下左右边距
const innerHeight = svgHeight - margin.top - margin.bottom //核心图形区域的高度
const innerWidth = svgWidth - margin.left - margin.right //核心图形区域的宽度
const svg = d3.select("body")
.append('svg') //添加画布,并设置宽高
.attr('width', svgWidth)
.attr('height', svgHeight);
svg.append('g') //用一个group包含后面要绘制的柱子,方便整体移动定位
.attr('transform', `translate(${margin.left},${margin.top})`)
//此处rect只是用于演示占位,并非柱状图的绘制部分
.append('rect')
.attr('fill','#a8ced7')
.attr('height', innerHeight)
.attr('width', innerWidth)
蓝色部分就是核心图形的区域,我们将会在这个区域内绘制柱状图。
(这篇文章的图形绘制将使用D3.js,前置知识可以参考 D3.js值得学习吗? 。)
核心问题:怎样将数据映射到图形上
人们对图像的感知能力远强于抽象的数字。数据可视化要做的,就是将数据通过某种算法映射到图形的视觉变量,让人们能够快速直观地理解数据。
const data = {
date: '2022-7-19',
visit: [
{time: '0:00', num: 21},
{time: '1:00', num: 15},
{time: '2:00', num: 13},
...
{time: '23:00', num: 23}
]
}
以这样一组数据为例,它代表了某个网站在一天中每个小时的访问量(假设单位为万)。
我们想要快速了解各个时段的访问量,以及整体趋势,统计访问量超过20万的时段有哪些。
data.visit
中有time
和num
两个维度的数据。要绘制柱状图,我们会很自然地想到以time
为X方向,映射规则是 时间刻度从小到大对应 X方向从左到右的位置,num
为Y方向,映射规则是 访问量的多少对应柱子(矩形)的高度。
可视化中的数据映射基本上可以理解为 数据到图元属性的映射。
数据
数据可以分为 分类数据(categorical)、定序数据(Ordinal)和 定量数据(Quantitive)。
- 类别数据(categorical)。
类别数据不具有数值属性的,各个数据之间无顺序关系、相互独立,它主要描述数据的标签与分类等。比如物品销量【衬衫、鞋子、裙装...】、城市【北京、上海、重庆...】。 - 定序数据(Ordinal)。
定序数据是一系列有确定顺序关系的数据,大小、前后、高低等。比如衣服尺寸【xs、s、m、l、xl...】、月份【1、2、3...】,虽然月份是数字,但是它们不具备计算属性,也就是不能被加减乘除。 - 定量数据(Quantitive)。定量数据简单来讲就是数字,具备计算属性。如访问量、销售额等。
图元属性
图元是组成图表的基础,如下图中的点(points)、线(lines)、面(areas)。
图元属性指的是图元的位置、颜色、形状、斜率、尺寸等属性。在图表中,数据的异同往往映射在图元属性上,而图元属性的差异人们是能够直观感知的。
映射与比例尺
以时间刻度为例,时间刻度【'0:00','1:00',... '23:00'】从小到大对应 X方向从左到右的位置,具体映射就是:'0:00'对应0,'23:00'对应innerWidth(核心图形区域的宽度),中间的时刻均匀分布在【0,innerWidth】区间内
。
这有点像数学中的函数y=f(x)
,根据输入的 数据 值,求得 图元属性 的值。
D3中的比例尺scale是用于处理映射的工具函数,它把一组输入域(domain)映射到输出域(range)。使用这个工具,我们就不用关心具体的映射过程,只需要关注比例尺scale的类型,输入域(domain)是什么,输出域(range)是什么这三个问题即可。
我们要将访问量数值映射到柱状图中柱子的高度,这里要使用的比例尺类型是线性比例尺scaleLinear
,这是定量数据(Quantitive)常用的比例尺。
const scale = d3.scaleLinear().domain([0,5]).range([0,100])
//相当于,已知 0 = scale(0) , 100 = scale(5), 求scale这个线性方程:scale(x) = 20*x
scale(1) // 输出:20
scale(3) // 输出:60
scale(5) // 输出:100
在我们将要绘制的柱状图中,将 访问量(domain) 线性映射到 高度(range)。
const yScale = d3.scaleLinear()
.domain([0, d3.max(data.visit, d => +d.num)]) //domain是[0, 访问量的最大值]
.range([0, innerHeight]); //range是[0, 核心图形区域的高度]
svg.append('g')
.selectAll('rect')
.data(data.visit)
.enter()
.append('rect') //根据访问量数据生成柱子
.attr('height', (d,i) => { //将数据传入比例尺函数,计算矩形高度
return yScale(d.num)
})
.attr('y', (d,i) => {
return innerHeight - yScale(d.num) //根据矩形高度计算矩形的 y 属性
})
...
在X方向,时刻的映射将使用scaleBand()比例尺
,它接收离散的值域domain,将其映射为连续的数值范围range。
let scale = d3.scaleBand().domain(['0:00', '1:00','2:00']).range([0,100])
相当于为每个离散值分配一段连续的区间,返回值是离散值对应区间的最小值。
scale('0:00') // 输出:0
scale('1:00') // 输出:33.333333333333336
scale('2:00') // 输出:66.66666666666667
const xScale = d3.scaleBand()
.domain(data.visit.map(item => item.time)) //domain是由时刻组成的数组
.range([0,innerWidth]) //range是[0, 核心图形区域的宽度]
svg.append('g')
...
.append('rect')
.attr('width', barWidth)
.attr('x', (d,i) => {
return xScale(d.time) //根据数据的时刻值计算出矩形的 x 属性
})
绘制结果如下:
同样重要:非核心图形的组成部分
坐标轴
D3 提供了坐标轴的组件,在 SVG 画布中绘制坐标轴只需要调用一些方法。
要生成坐标轴,需要用到比例尺,以X坐标轴为例:
//d3有axisTop\axisBottom\axisLeft\axisRight四种组件,分别对应上下左右的坐标轴
const xAxis = d3.axisBottom()
.scale(xScale) //xScale就是上面定义的xScale
svg.append('g').call(xAxis) //将坐标轴添加到svg画布中
添加的组件默认都位于画布左上角(0,0)
的位置,可以通过 transform 属性来把它移到合适的位置。
svg.append('g').call(xAxis)
.attr('transform', `translate(${margin.left}, ${svgHeight - margin.bottom})`)
接下来调整一下细节:
const xAxis = d3.axisBottom()
.scale(xScale)
.tickPadding(10) //设置刻度和文字的间距
.tickValues(['0:00', '8:00', '12:00', '17:00', '23:00']) //只显示这几个关键的刻度
svg.append('g').call(xAxis)
.attr('transform', `translate(${margin.left}, ${svgHeight - margin.bottom})`)
.select('.domain').remove() //去掉底部黑色的横线
以类似的方法添加Y坐标轴:
const yScale = d3.scaleLinear()
.domain([0, d3.max(data.visit, d => +d.num)])
.range([innerHeight, 0]);
const yAxis = d3.axisLeft()
.scale(yScale)
.tickPadding(15) //设置刻度和文字的间距
.ticks(3) //显示3个刻度
.tickSize(-innerWidth) //虚线长度布满柱状图区域
svg.append('g').call(yAxis)
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.select('.domain').remove()
标题
图表的标题可以直接用svg的<text>标签
添加:
svg.append('text')
.html('Alaso的编程纪 2022/7/19 访问量(单位:万次)')
.attr('x', margin.left - 15)
.attr('y', margin.top/2)
.attr('transform', 'translate(0,9)')
.attr('font-size', 18)
tooltip
我们希望鼠标移到柱子上时,柱子颜色变化,并且显示相关信息。
柱子是<rect>
标签,这使我们可以像对待普通dom
元素这样,为它们添加hover
效果,添加mouseover事件
//css代码
.bar:hover{ //生成rect的时候给它添加 class=bar
fill: #a8ced7
}
//js代码
//添加tooltip容器
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tool-tip')
//添加mouseover事件,在tooltip中显示每个柱子绑定的信息
svg.append('g')
...
.enter()
.append('rect')
...
.on('mouseover', ev => {
const data = ev.srcElement.__data__
tooltip.html(`时刻:${data.time}<br>访问量:${data.num}万次`)
.style('left', ev.pageX + 'px')
.style('top', ev.pageY + 'px')
.style('opacity', 1)
})
// 当鼠标移出柱子时,隐藏tooltip
svg.on('mouseover', ev => {
if(ev.target.tagName !== 'rect'){
tooltip.style('opacity', 0)
}else{
tooltip.style('opacity', 1)
}
})
D3.js的迷人之处:如果你想加点个性化的东西
统计超过20的部分
将超过20的部分填充色设置为浅绿色,就能一眼看到超出20的部分分布在哪些时段。
//在矩形填充的时候加一个条件判断
.attr('fill', (d) => d.num > 20 ? '#9cc960' : '#ec5487')
加上一些小图标
和图表库配置画图表不同,因为这里什么都是自己画,所以在经历过苦以后的一点甜就是,想在画布上画什么都可以。
在这里,我们可以画上太阳和月亮,标识白天和黑夜。
//以月亮组件为例
//定义月亮组件
<symbol viewBox="-5 -28 520 510" id="moon">
<path fill = "#4ea3a1"
d="m224.023438 448.03125c85.714843.902344 164.011718-48.488281 200.117187-126.230469-22.722656 9.914063-47.332031 14.769531-72.117187 14.230469-97.15625-.109375-175.890626-78.84375-176-176 .972656-65.71875 37.234374-125.832031 94.910156-157.351562-15.554688-1.980469-31.230469-2.867188-46.910156-2.648438-123.714844 0-224.0000005 100.289062-224.0000005 224 0 123.714844 100.2851565 224 224.0000005 224zm0 0" />
</symbol>
//将月亮组件添加到画布
svg.append('use')
.attr('href', '#moon')
.attr('height', 40)
.attr('width', 40)
.attr('x', innerWidth)
.attr('y', margin.top)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。