手把手带你上手D3.js数据可视化系列(四) - 知乎

本系列 D3.js 数据可视化文章是古柳按照自己想写的逻辑来写的,可能和网上的教程都不太一样,至于会写多少篇、写成什么样,古柳也完全心里没数,虽然是奔着初学者也能轻松看懂的目标去的,但真的大家看完觉得有什么感受,古柳也不清楚,所以希望大家多多反馈,后续文章能改进的也继续改进,并且有机会的话基于这个系列再出个视频教程,但那是后话了

配套代码和用到的数据都会开源到这个仓库,欢迎大家 Star,其他有任何问题可以群里交流:

DesertsX/d3-tutorial​github.com/DesertsX/d3-tutorial

前言

上一篇文章「手把手带你上手D3.js数据可视化系列(三)- 牛衣古柳 - 2021.08.20」里,古柳基于爬取的真实数据,带大家用 D3.js 简单实现了个可视化效果,照理可以继续讲下如何给矩形添加鼠标事件来显示与隐藏 tooltip 提示框以展示每个 Up 的相关信息、或者给图例添加点击事件来显示特定分区的 Up 主等等。但可能本身可视化效果没有多好,就懒得加这些内容。

原本这篇文章是想带大家实现 How healthy is the web? 这个作品的效果(非完整效果),但古柳觉得有必要先讲些可能用到的知识点,以及前几篇还没来得及讲到的内容。先过一遍基础、有些准备后再去讲解实现会比较好。
链接:https://www.torre.nl/webindex/#top

碰上合适的作品然后讲些相关的实现方式,实战中边教边练,以免大家学了一堆基础知识,等到真要复现作品时又下不了手、要踩很多坑。

打个组后统一移动

上一篇文章里讲到,可以用 g 元素对可视化里不同区域进行打组以便区分;其实 g 元素还有一个妙用,就是对一些元素打组后方便统一移动,然后每个组内的元素的坐标就可以用相对坐标来简单设置。怎么个简单法,看看下面的例子就知道了。

基本代码结构

基本代码结构没啥好说的,和之前的差不多。设置 svg 画布宽高和背景色;简单构造数组数据 dataset,即 [0, 1, 2, 3, 4, 5]

<!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">
    <meta name="referrer" content="no-referrer">
    <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>
        function drawChart() {
            const dataset = d3.range(6)

            const svg = d3.select('#chart')
                .append('svg')
                .attr('width', window.innerWidth)
                .attr('height', window.innerHeight)
                .style('background-color', '#FEF5E5')
        }

        drawChart()
    </script>
</body>

</html>

绑定数据绘制元素

方式1:土方法

首先,按照前几篇文章里教的方法,在画布上绘制一组矩形和文字,并且文字居中在矩形中心,这里还是用 .selectAll('rect').data(dataset).join('rect') 这样的步骤绘制需要的元素。绘制矩形时是设置左上角坐标,画完矩形,文字居中就是再加上矩形宽高的一半。

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

const rectWidth = 50
const rectHeight = 100
const radius = rectWidth / 2 - 10

// 方式1
const group1 = svg.append('g')
const rects = group1.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 150 + d * 75)
    .attr('y', 50)
    .attr('width', rectWidth)
    .attr('height', rectHeight)
    .attr('fill', d => colors[d])

const text = group1.selectAll('text')
    .data(dataset)
    .join('text')
    .attr('x', d => 150 + d * 75 + rectWidth / 2)
    .attr('y', 50 + rectHeight / 2)
    .text(d => d)
    .attr('fill', '#fff')
    .style('text-anchor', 'middle')
    .style('dominant-baseline', 'middle')

这里顺带介绍下如何绘制圆圈/circle,也很简单,只需设置圆心坐标 cx/cy 和半径 r,和文字的坐标一致均是再加上矩形宽高的一半(rect 的 x/y 是左上角顶点的坐标而不是中心的坐标,注意区分),然后 fill 填充颜色为透明,stroke 描边颜色为白色且宽度为 2px 即可。后面填充、描边等属性其他 svg 元素也都有。

const circles = group1.selectAll('circle')
    .data(dataset)
    .join('circle')
    .attr('cx', d => 150 + d * 75 + rectWidth / 2)
    .attr('cy', d => 50 + rectHeight / 2)
    .attr('r', radius)
    .attr('fill', 'transparent')
    .attr('stroke', '#fff')
    .attr('stroke-width', 2)

需要注意下绘制顺序,后绘制的元素会覆盖遮挡在先绘制的元素上,假如矩形是最后绘制的,那么圆圈和文字被遮挡后就看不到了。当然遮挡后也可以通过开起颜色混合模式进行解决,很快就会带大家实现「一场因颜色混合模式而开启的视觉盛筵!- 牛衣古柳 - 2021.03.29」一文的效果。

动图封面

方式2:更优雅的实现

但上面的绘制方式很繁琐,首先每次都需要通过 .data(dataset) 重复绑定数据到想绘制的元素上,而且每类元素的坐标都是按基于画布左上角的绝对坐标来设置的,写起来很麻烦,修改起来也很不方便。

这时候就可以用到开头提到的 g 元素打组的方法;其实当一条数据会用来绘制多种元素时都可以考虑这一方法。

具体就是这次把数据绑定到 g 元素上,依旧是这样三步骤.selectAll('g').data(dataset).join('g');并且和上面 rect 一样放置到相应位置上,不过这里是通过 transform 属性进行设置。注意这里 group2 是为了和方式1里的 group1 一样都保留下来,方便大家比较,所以先平移到下方以便绘制时重叠;至于水平的 150px 像素是 group2 先统一平移还是在每个 g 元素里分别加上,两者都可以。

// 方式2
const group2 = svg.append('g')
    // .attr('transform', 'translate(0, 200)')
    .attr('transform', 'translate(150, 200)')

const groups = group2.selectAll('g')
    .data(dataset)
    .join('g')
    // .attr('transform', d => `translate(${150 + d * 75}, 0)`)
    .attr('transform', d => `translate(${d * 75}, 0)`)

这时候在每个 g 元素里直接通过 append 添加需要的元素就行,由于此时每个元素的坐标都是基于各自 g 元素的,所以只需写相对坐标、进行相对偏移即可。

比如这里 rect 的 x/y 其实就是 0/0,因为默认就是 0/0,所以可以不用写,而 text 和 circle 也只需直接定位到矩形宽高的一半位置即可。

而属性的设置如果需要用到绑定的数据,也和以前一样通过回调函数的方式即可,比如 d => colors[d],这里会自动按数据绑定的顺序取到相应的每条数据。

groups.append('rect')
    .attr('width', rectWidth)
    .attr('height', rectHeight)
    .attr('fill', d => colors[d])

groups.append('text')
    .attr('x', rectWidth / 2)
    .attr('y', rectHeight / 2)
    .text(d => d)
    .attr('fill', '#fff')
    .style('text-anchor', 'middle')
    .style('dominant-baseline', 'middle')

groups.append('circle')
    .attr('cx', rectWidth / 2)
    .attr('cy', rectHeight / 2)
    .attr('r', radius)
    .attr('fill', 'transparent')
    .attr('stroke', '#fff')
    .attr('stroke-width', 2)

相比之下,方式2的写法简单了多少已经不言自明。

既然那么简单,就再多画些之前没画过的元素,这里顺带再介绍下如何绘制 line 线段,同样很简单,只需要设置起点终点的坐标 (x1, y1)(x2, y2),比如这里在圆圈底部画条和矩形宽度等长的水平线段,同样是相对坐标,很方便就能写出。

groups.append('line')
    .attr('x1', 0)
    .attr('y1', rectHeight / 2 + radius)
    .attr('x2', rectWidth)
    .attr('y2', rectHeight / 2 + radius)
    .attr('stroke', '#fff')
    .attr('stroke-width', 2)

基于某属性的数值大小绘制不同数量的 svg 元素

接下来讲个一般来说不太会那么早出现,甚至挺多教程里不一定会涉及,但其实古柳觉得还挺有用的知识点。

在将数据里的各种属性映射到视觉元素时,除了常见的映射成 x/y 坐标、颜色等之外,有时也会将某一维属性里的数值以 svg 元素数量多少的形式进行展示。

比如在 MeToomentum 项目里的 Trending seeds 作品里,一条条推特上 #MeToo 标签下的推文以一个个蒲公英种子(即小圆圈)进行表示:种子离中心的距离和转推数相关;种子的大小表示发推文的用户被关注人数的多少;种子的四种颜色表示推文获得的赞同数;种子上的条纹则表示推文获得的评论数,相关阅读:「MeToomentum 可视化作品复现系列文章(一) - 牛衣古柳 2020.10.16」

将一条条推文数据绘制成一个个圆圈想来大家都不陌生了(当然数据属性如何映射还有些地方没讲过,但绑定数据绘制圆圈的三步骤和之前都是一样),但这里如何根据评论数多少绘制数量不一的“条纹”/曲线,其实值得单独拎出来讲讲。

这里还是以本次简单构造的 dataset 数据集 [0, 1, 2, 3, 4, 5] 来演示如何在每组里添加与数值相等数量的小圆圈。

不完美的方式1

前面讲到在每个 g 元素里添加一个元素时直接用 append() 即可,如何想添加多个元素,同样需要用 .selectAll('circle').data(data).join('circle') 这样的方式,只不过这里需要用之前 g 元素绑定的数据,即 dataset 里的每一项,来另外构造一个个新的数组数据集,由于每个新数据集长度不同,所以这里也会添加上数量不同的圆圈,注意这里加了 class 以便和前面绘制的文字外的一圈圆圈区分来,不至于选中那个圆圈。

groups.selectAll('circle.dot')
    .data(d => d3.range(d))
    .join('circle')
    .attr('class', 'dot')
    .attr('cx', (d, i) => 3.5 + i * 10)
    .attr('cy', rectHeight + 15)
    .attr('r', 3.5)
    .attr('fill', d => colors[d])

需要注意的事这里虽然有很多回调函数,但是 .data(d => d3.range(d)) 和后面的含义并不一样,这里的 ddataset 里的每一项,假如 d 是 3,则会构造出新数组 [0, 1, 2],接着新数据集会绑定到小圆圈上,最后属性设置时如 (d, i) => 4 + i * 10 里的 d,i 就是新数组里的每项和索引。

但这里有个问题,假如想把小圆圈的颜色填充成和每个 g 里矩形颜色一致时是无法实现的。因为这需要知道最初 dataset 里的每一项数据,而构造出来的新数组里的每一项是无法知道最初的数据,比如新数组 [0, 1, 2] 里每一项没法知道最初的 d 是 3,也就是新数组的长度,所以这里小圆圈只能简单填充成不同颜色。

改进后的方式2

有一种解决办法是,把构造的新数组里每项改成对象格式,并且把最初的原始数据项 d 给带上,这时新数组不再是 [0, 1, 2],而变成了下面注释的格式。

groups.selectAll('circle.dot')
    .data(d => {
        return d3.range(d).map(item => ({
            dot: item,
            count: d
        }))
    })
    .join('circle')
    .attr('class', 'dot')
    .attr('cx', (d, i) => rectWidth / (d.count + 1) * (i + 1))
    .attr('cy', rectHeight + 15)
    .attr('r', 3.5)
    .attr('fill', d => colors[d.count])

// 新数组
// [
//     { dot: 0, count: 3 },
//     { dot: 1, count: 3 },
//     { dot: 2, count: 3 }
// ]

这时候绘制的小圆圈不仅能填充成统一对应的颜色,而且还能采取平均分布在下方宽度上,因为这里也会用到每组里圆圈个数。采用平均分布而不是每组里从左到右间隔排列,也更好看些。平分的公式并不难可以自行理解下,就不过多解释了。

上面的实现方式也是 How healthy is the web? 作品源码里绘制每个国家的4个指数对应的矩形时采用的方式。这里一个国家一条数据,一个数据一个 g 元素,由于g元素里有4个矩形,所以也会从一条数据里提取出4个指数数值组成新数组数据以便分别绘制4个矩形元素,具体实现方式后续会介绍。
链接:https://www.torre.nl/webindex/#top

现学现卖的方式3

扯回来,绘制上述小圆圈的方法不止一种,哪怕用 forEach() 遍历初始数据来生成新数组然后绘制圆圈也不是不行,虽然并不推荐。

还有一种方法是古柳此前自己虽然没用过,但在各种作品的源码曾多次见过,不过一直没了解过用法,这次也是趁这个机会看了下官方文档说明,感觉能用上,就现学现卖介绍下。
链接:https://github.com/d3/d3-selection#selection_each

这里的英文就不翻译了,其实还是针对之前的“痛点”,使用 .each() 后就能同时访问到最初数据和新数据集里的每项数据,access parent and child data simultaneously,其中 .each() 里的回调函数和之前的回调函数的参数一样,常用的就是绑定的数据项和索引,只不过这里是最初绑定的数据,而函数里可以再生成新数据集并进行绑定和绘制元素,此时设置属性时就能同时使用父数据 item 和子数据 d。

注意 .each() 的回调函数不能用箭头函数,否则函数里 this 指向会变成 window,而不再是当前节点,具体可搜 JavaScript 里的 this 指向相关内容进行了解。

groups.each(function (item, index) {
    console.log(item, index, this)
    d3.select(this)
        .selectAll('circle.dot')
        .data(d3.range(item))
        .join('circle')
        .attr('class', 'dot')
        .attr('cx', (d, i) => rectWidth / (item + 1) * (i + 1))
        .attr('cy', rectHeight + 15)
        .attr('r', 3.5)
        .attr('fill', colors[item])
})

这种方式应该是最推荐的,虽然古柳还用的不多,没什么心得。但总之这里就是和大家介绍了下当数据里有一个属性是数值格式,且打算用 svg 元素数量来表示时就可以用到这里介绍的方法。其实有点嵌套数据一层层往里绘制的感觉。

文章也不短了,原本还想简单讲讲如何给每个 g 元素添加事件交互。并且用上篇文章里的b站up主数据做个简单效果,即点击后按不同属性排序然后变换位置的效果,但来不及讲了,还是留下一篇再介绍。

00:04

照例

最后多多交流,对本文任何地方有疑惑的可以群里提问。

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


原网址: 访问
创建于: 2023-01-12 16:34:10
目录: default
标签: 无

请先后发表评论
  • 最新评论
  • 总共0条评论