阅读 407

手把手带你上手D3.js数据可视化系列(一)

前言

上一篇文章「安利一些不错的D3.js资源 - 牛衣古柳 2021.06.29」的反响还不错,记得有新群友说是主管推给她文章才加过来的,也是很神奇。

一眨眼又一个月没更新了。其实一直有想写简单的 D3.js 入门文章/教程的打算,但总想着要写就写的全面细致些、有趣些、够通俗易懂些,甚至如果能对标 Daniel ShiffmanProcessing、P5.js 等方面的输出,能真的让更多人更顺滑地入门 D3.js 可视化就好了。相关阅读:伴随 P5.js 入坑创意编程 - 牛衣古柳 2019.06.28

理想很丰满,现实很骨感。古柳自身水平不够就不提了,至今没积累多少案例可以支撑实现上面的目标,还经常因为一段时间没接触 D3.js 就忘个精光,再次拿起来用自己都磕磕绊绊,更何谈输出教程呢?

说起来也很沮丧,古柳一直觉得自己提供不了什么可视化相关有价值的内容,如果连写入门教程的事也无法实现的话,真的不知道还能做些什么。

但一直拖着不行动也不行,仍然心有不甘。纵使无法一上来就输出较系统全面、够通俗易懂的教程,很多地方可能无法达到心中的目标,但姑且先行动起来,看看到底能写出什么样的内容再说。优化迭代等有所输出后再进行也来得及

因而就有了这篇文章,有了这个系列里的第一篇文章,至于本系列能写多少,到底会写成什么样,古柳也完全心里没数,就让时间来说明一切吧,另外虽然是奔着初学者也能轻松看懂的目标去的,但真的大家看完觉得有什么感受,古柳也不清楚,所以希望大家多多反馈,后续文章能改进的也继续改进

本系列配套代码和用到的数据都会开源到这个仓库,欢迎大家 Star,其他有任何问题可以群里交流:github.com/DesertsX/d3…

正文

基本代码结构

首先,介绍下代码结构,id为"chart"的div元素将用于放后面添加的 SVG 画布;引入下载到本地的 D3.js 库(v5.9版本);JS 部分就是本次代码的重点,且都在 drawChart() 函数里实现。另外 CSS 样式主要是为后续画布能全屏撑满不留空白所用。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>D3.js 教程</title>
    <style>
        * {
            margin: 0;
            padding: 0;
        }

        html,
        body {
            overflow: hidden;
        }
    </style>
</head>
<body>
    <div id="chart"></div>
    <!-- 可以下载到本地也可以引用线上版本 -->
    <script src="./d3.js"></script>
    <!-- <script src="https://cdn.bootcdn.net/ajax/libs/d3/5.9.7/d3.js"></script> -->
    <script>
        function drawChart() {
            // code
        }

        drawChart()
    </script>
</body>
复制代码

添加 SVG 画布

以下 JS 代码都是在 drawChart() 的。

D3.js 进行可视化,可以用矢量图的 SVG,也可以用标量图、像素的canvas,因为古柳 SVG 用的多些,这里就以此为例。

可视化画图过程简单说来就是把数据映射成视觉元素,再以特定方式布局到画布上。其中视觉元素可以是散点图里的圆圈,柱形图、直方图里的矩形,折线图里的线条等等;布局核心是要知道每个元素的x/y坐标,可以是自己计算出来,也可以是 D3.js 自带的许多布局函数生成的。

接下来以矩形为例,带大家看看 D3.js 的一些用法。

首先需要一个 SVG 画布来放置后续的视觉元素,其实还会放标题/坐标轴/图例等等,这里可能还用不到,以后会介绍。

通过 d3.select() 选中 id 为 chart 的 div 元素,这里的#就是表示id,如果是class就是.,很简单的 CSS 选择器用法;

接着通过 append 添加 svg 元素,然后设置其的宽高和背景色,这里为了演示方便,设置成浏览器网页窗口高度的全部和宽度的一半,大家也可以撑满网页窗口,或者用固定大小如 900*600 等,视自己需求而定即可。

const width = window.innerWidth
const height = window.innerHeight

const svg = d3.select('#chart')
    .append('svg')
    .attr('width', width / 2)
    .attr('height', height)
    .style('background', '#FEF5E5')
复制代码

其中 window.innerWidthwindow.innerHeight 就是网页窗口在某一大小打开时的宽高,即图中红框部分,并且可以看到画布占了一半大小。

画布设置好后,就可以往里面添加视觉元素了,就像很多工具/软件都自带一些基本图形元素一样,SVG 也有 circle/rect/ellipse/polygon/line/path/text 等常用元素,并且每个元素可以设置相应属性,如位置、宽高、半径、颜色、描边、透明度等等(图片取自 fullstack d3),后续会逐渐介绍,都不复杂。

现在我们要在画布里画一个矩形/rect,同样用 append 加上元素名即可,然后设置 x/y 位置坐标(矩形左上角的坐标,而不是中心点的坐标)、矩形宽高(数字均为像素值,如100就是100px)和颜色即可。

需要注意的是:直角坐标系原点在网页窗口左上角,水平向右是x轴正轴,垂直向下是y轴正轴。

svg.append('rect')
    .attr('x', 30)
    .attr('y', 50)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', '#00AEA6')
复制代码

对应浏览器里生成的 HTML 的内容如下。

<svg width="518.5" height="680" style="background: rgb(254, 245, 229);">
    <rect x="30" y="50" width="50" height="100" fill="#00AEA6"></rect>
</svg>
复制代码

假如矩形画在画布边缘,超出画布部分是不可见的。所以如果数据多了,就需要换行显示,后面会演示如何处理。

svg.append('rect')
    .attr('x', width / 2 - 25)
    .attr('y', 50)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', '#EB5C36')
复制代码

上面演示了如何添加一个元素,但更多时候我们需要根据数据集来添加多个元素,那该如何操作呢?

可能有人想到可以遍历循环数据来添加元素......其实倒也不是完全不行。

构造简单数据

这里用 d3.range(20) 简单构造个包含0-19数字的数组——[0, 1, 2, ..., 19]——作为演示的数据集;

const dataset = d3.range(20)
console.log(dataset) // [0, 1, 2, ..., 19]

const colors = ['#00AEA6', '#DB0047', '#F28F00', '#EB5C36', '#242959', '#2965A7']
复制代码

并且准备了6种颜色,模拟可视化时将某一类别型属性映射成不同颜色的情况。配色取自于此图,很好看有没有,可是古柳静心挑选的!

因为颜色数据也是数组,而取数组里某项元素可以通过索引来进行,比如取第一个颜色就是 colors[0],索引从0开始到数组长度减1结束,即 colors.length - 1,对应颜色是 colors[colors.length - 1],都是比较基础的 JS。

遍历数据来添加元素

接着遍历数据来添加元素就可以这样实现,当然用 for 循环也可以,这里简单着来,采用 forEach 遍历每项元素,d 依次是0-19每个数字,如果一行排列,可以间隔 70px 排开,d * 70 相当于就是等差数列;由于会超出画布所以无法显示全部。

dataset.forEach(d => {
    svg.append('rect')
        .attr('x', 20 + d * 70)
        .attr('y', 20)
        .attr('width', 50)
        .attr('height', 100)
        .attr('fill', colors[d % colors.length])
})
复制代码

其中每个矩形颜色是用数字对颜色数组长度取余数后作为索引值,然后从颜色数组里取色。数值的取整取余是很好用的操作,后续也会常常出现,下面是具体取余的一些例子。

0 % 6 => 0 => colors[0]
1 % 6 => 1 => colors[1]
2 % 6 => 2 => colors[2]
...
5 % 6 => 5 => colors[5]
6 % 6 => 0 => colors[0]
7 % 6 => 1 => colors[1]
...
19 % 6 => 1 => colors[1]
复制代码

D3.js 基于数据添加元素的方式

回到空白画布,下面的代码实现了和上面遍历循环一样的效果。

遍历循环数据来添加元素虽然有时候可行,但一般不会这么实现,更一般的、更 D3.js 的方式是用这样一组命令 .selectAll('rect').data(dataset).join('rect') 来基于数据添加元素。

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 20 + d * 70)
    .attr('y', 20)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', d => colors[d % colors.length])
复制代码

想来很多人第一次接触这一方式,都会觉得很奇怪吧?要用数据绘制矩形,需要先 selectAll('rect') 选中所有矩形,可现在明明画布为空,并不存在 rect 元素,仿佛选了个寂寞?后面 .data(dataset) 就是把数据集绑定到选中的元素上;.join('rect') 是实际添加元素的操作。

接着每个元素的属性通过回调函数的方式进行设置,其中 d 就是 dataset 里每一项的数据。固定值的属性可以直接写死,无需函数写法。

这里暂时不做过多解释,其实真实原因是古柳也解释不好,还要牵扯出 enter-update-exit 等一套概念((图片同样取自 fullstack d3)),很多人估计入门时就被这些概念绕晕了,所以目前大家只需要记住这是常规操作、很重要,绑定数据进行绘制元素时会频繁用到,而且记牢这三句即可。

当然大家看网上例子,一定会看到类似下面的写法,其中 .enter().append() 是以前版本 D3.js 的写法,用 .join() 替换即可,少写一句不也挺好;function() {} 也可以用 ES6 的箭头函数 => 替换,更简洁方便,推荐大家学些基础 JS 后也都像上面那样写。

const rects = svg.selectAll('rect')
    .data(dataset)
    .enter()
    .append('rect')
    .attr('x', function (d) {
        return 20 + d * 70
    })
    .attr('y', 20)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', function (d) {
        return colors[d % colors.length]
    })
复制代码

调整布局,换行显示

在上面的例子中,矩形都是一行排列,数据一多就会超出画布,接下来调整下布局,实现换行显示的效果。

x 坐标的计算公式是 20 + d * 70,这里希望每一行的最后一个矩形整体都在画布内,即 x 坐标加上矩形宽度要小于画布宽度。由此可以计算出一行最多放多少个矩形,以 col_num 命名,注意这里第 n 个元素对于的 d 其实是 n-1,因为 d 是从0开始的,元素确实从第一个元素开始的。

// 公式
20 + (col_num - 1) * 70 + 50 <= witdh / 2

// 等同于
col_num <= witdh / 2 / 70
复制代码

计算公式如上,因为除法有小数,这里需要向下取个整数,用 Math.floor()parseInt 均可。

const col_num = parseInt(width / 2 / 70)
// const col_num = Math.floor(width / 2 / 70)
console.log(col_num)
复制代码

算出每列的个数后,就能继续用上文提到的取整取余操作来计算每个元素的x/y坐标,其本质就是需要知道每个元素在哪一行哪一列。

const dataset = d3.range(50)

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 20 + d % col_num * 70)
    .attr('y', d => 20 + Math.floor(d / col_num) * 120)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', d => colors[d % colors.length])
复制代码

比如每一行x坐标等差变化,通过 d % col_num 取余得到元素在每一行里的位置并计算到x坐标上;每一列y坐标等差变化,通过 Math.floor(d / col_num) 取整得到元素在每一列里的位置并计算到y坐标上。这里初学者如果没理清的话可以再梳理下。

需要注意的是上面改了 dataset,生成0-49的50条数据,以方便尽量撑满画布。所以截止目前,通过运用取余取整操作,在画布上较好的绘制出了所有数据。

但如果当数据更多时,超出最大高度又该怎么办呢?

也许可以缩小矩形宽高,然后调节间距一步步搞定。(这里古柳就不调了,主要是引出这个问题)

const dataset = d3.range(100)

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 20 + d % col_num * 70)
    .attr('y', d => 20 + Math.floor(d / col_num) * 120)
    .attr('width', 50 / 2)
    .attr('height', 100 / 2)
    .attr('fill', d => colors[d % colors.length])
复制代码

是否能基于数据大小和画布宽度来自动计算出每个rect的宽高和间距,然后自动布局呢?

正好古柳之前啃大西洋手抄本可视化作品源码时看到了能解决上述问题的实现方式,将在下一篇文章分享给大家,更多 D3.js 内容也将会在下一篇文章继续展开讲解,敬请期待。

相关阅读:迄今复现过最复杂的可视化作品之「大西洋古抄本」(上) - 牛衣古柳 2021.06.17迄今复现过最复杂的可视化作品之「大西洋古抄本」(下) - 牛衣古柳 2021.06.22

以后,欢迎来「可视化交流群」一起交流,加古柳微信「xiaoaizhj」备注「可视化加群」拉你进群哈!

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

文章分类
前端