自从开始DIY图表,体验和使用图表库完全不同。在这个过程中,你需要思考的问题会比使用图表库多很多,比如:

1、要绘制的图表有哪些部分组成?

2、怎样将数据映射到图形上?

3、交互的逻辑是什么?

这篇文章也主要围绕这几个问题展开。让我们开始吧!

图表有哪些部分组成

图表通常分为 核心图形区域 和 非核心图形区域。核心图形区域是根据数据绘制图形的区域,它承载了图表的主要信息。非核心图形区域为图表提供必要的补充信息,如标题,图例说明,横纵坐标的含义及刻度。

image.png

当我们在一块空白区域绘制图表时,首先要做的,就是规划整个图表的大致框架,确定画布的宽高,核心图形区域位于整个画布的什么位置,上下左右边距分别是多少,各种补充信息分别位于哪些位置

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)

蓝色部分就是核心图形的区域,我们将会在这个区域内绘制柱状图。

image.png
(这篇文章的图形绘制将使用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中有timenum两个维度的数据。要绘制柱状图,我们会很自然地想到以time为X方向,映射规则是 时间刻度从小到大对应 X方向从左到右的位置num为Y方向,映射规则是 访问量的多少对应柱子(矩形)的高度

可视化中的数据映射基本上可以理解为 数据到图元属性的映射

数据

数据可以分为 分类数据(categorical)、定序数据(Ordinal)和 定量数据(Quantitive)。

image.png

  • 类别数据(categorical)。
    类别数据不具有数值属性的,各个数据之间无顺序关系、相互独立,它主要描述数据的标签与分类等。比如物品销量【衬衫、鞋子、裙装...】、城市【北京、上海、重庆...】。
  • 定序数据(Ordinal)。
    定序数据是一系列有确定顺序关系的数据,大小、前后、高低等。比如衣服尺寸【xs、s、m、l、xl...】、月份【1、2、3...】,虽然月份是数字,但是它们不具备计算属性,也就是不能被加减乘除。
  • 定量数据(Quantitive)。定量数据简单来讲就是数字,具备计算属性。如访问量、销售额等。

图元属性

图元是组成图表的基础,如下图中的点(points)、线(lines)、面(areas)。

image.png

图元属性指的是图元的位置、颜色、形状、斜率、尺寸等属性。在图表中,数据的异同往往映射在图元属性上,而图元属性的差异人们是能够直观感知的。

image.png

映射与比例尺

以时间刻度为例,时间刻度【'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

image.png

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 属性
    })

绘制结果如下:

image.png

同样重要:非核心图形的组成部分

坐标轴

D3 提供了坐标轴的组件,在 SVG 画布中绘制坐标轴只需要调用一些方法。

要生成坐标轴,需要用到比例尺,以X坐标轴为例:

//d3有axisTop\axisBottom\axisLeft\axisRight四种组件,分别对应上下左右的坐标轴
const xAxis = d3.axisBottom()  
    .scale(xScale)  //xScale就是上面定义的xScale
    
svg.append('g').call(xAxis)  //将坐标轴添加到svg画布中

image.png

添加的组件默认都位于画布左上角(0,0)的位置,可以通过 transform 属性来把它移到合适的位置。

svg.append('g').call(xAxis)  
    .attr('transform', `translate(${margin.left}, ${svgHeight - margin.bottom})`) 

image.png

接下来调整一下细节:

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()    //去掉底部黑色的横线

image.png

以类似的方法添加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)

image.png

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)
    }
})

tooltip.gif

D3.js的迷人之处:如果你想加点个性化的东西

统计超过20的部分

将超过20的部分填充色设置为浅绿色,就能一眼看到超出20的部分分布在哪些时段。

//在矩形填充的时候加一个条件判断
.attr('fill', (d) => d.num > 20 ? '#9cc960' : '#ec5487')

image.png

加上一些小图标

和图表库配置画图表不同,因为这里什么都是自己画,所以在经历过苦以后的一点甜就是,想在画布上画什么都可以。

在这里,我们可以画上太阳和月亮,标识白天和黑夜。

//以月亮组件为例
//定义月亮组件
 <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)

image.png


Alaso
44 声望7 粉丝