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

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

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

前言

前两篇文章「手把手带你上手D3.js数据可视化系列(一) - 牛衣古柳 - 2021.07.30」「手把手带你上手D3.js数据可视化系列(二) - 牛衣古柳 - 2021.08.10」主要为了带大家熟悉 D3.js 绘制 SVG 元素等操作,所以其他地方怎么简单怎么来,比如数据拿直接生成的自然数数组已经够用,就避免引入更多概念,不在新手教程里一次性灌输太多内容,而是尽量拆分知识点。

const dataset = d3.range(30)

现在大家对在画布上绘制元素应该不陌生了,那么古柳就继续讲解下如何读取真实数据集、对数据进行相应处理、基于数据绘制元素、将类别属性映射成对应颜色,以及比例尺的使用、文本元素绘制、图例的实现等相关内容。

当然一切还是在前两篇文章的基础上进行,所以这回依旧用矩形作为主要的视觉元素。

一开始古柳的设想是最好数据里有类别型属性,这样方便讲解颜色比例尺以及实现关于各类别数量的图例等内容,也方便为后续文章做好铺垫。

原本想用书籍(或电影)这类数据集,这样年末大家整理看过的书单(如果大家真的看了很多书的话,doge)时或许就能参照本文代码,以可视化的方式清晰明了地展示看过的书都是什么类型的。

但古柳也没想到合适的书籍数据集,后来想到2020年度b站百大Up主的数据还行,可以拿来看看他们都是什么分区的。当然本文就不涉及获取数据步骤,一讲解就会很冗长,后续会写个番外篇进行介绍。


这里只需知道分区数据是从Up主个人主页“投稿”栏下的“视频”处获取的,并且简单地以数量最多的区作为Up主所属分区,不一定很准确,仅作为教程里演示的例子而已。


这里先看下最终效果图,


基础代码

这次的样式和前两篇的略有不同,主要是居中放置 div#chart 元素,并且后续 SVG 画布采取固定宽高方式设置。不过这些都不是很关键,看自己需求怎么设置都行。

<!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;
        }

        body {
            background: #f5e6ce;
            height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
        }
    </style>
</head>

<body>
    <div id="chart"></div>
    <script src="./d3.js"></script>
    <script>
        function drawChart() {
            // ...
        }

        drawChart()
    </script>
</body>

</html>

读取数据

很多时候,可视化用到的数据存储在 CSVJSON 文件里,这时直接用 d3.csv()d3.json() 读取数据即可。不过由于读取数据是异步操作,需要加上 await 关键词以确保读取到数据后再去执行后续代码,同时函数外也需配套地加上 async 关键词,这里就不讲解异步操作与同步操作、宏任务与微任务等概念了,大家可自行了解。

async function drawChart() {
    let dataset = await d3.json('2020_bilibili_upzhu.json')
    console.log(dataset[0])
    console.table(dataset)
}

drawChart()

大家只需知道以后网上看到类似下面读取数据的操作,都能改成上面 async await 的方式即可,写起来也更舒服。

d3.csv("data.csv", function (error, dataset) {
     console.log(dataset)
});

d3.json('data.json').then(dataset => {
    console.log(dataset[0])
    console.table(dataset)
})

数据格式

这里介绍下数据格式,json 文件里是100个up主的相关数据,本文暂时只用到昵称 name 和分区数据 tlist,并且数据处理后会新增两个属性 fieldfieldId,以便后续使用。

[
  { 
    name: "老师好我叫何同学",
    uid: "163637592",
    tlist: [ 
        { tid: 160, count: 4, name: "生活" },
        { tid: 188, count: 32, name: "科技" },
        { tid: 217, count: 1, name: "动物圈" },
        { tid: 36, count: 5, name: "知识" },
    ],
    likes: 28123374,
    view: 216333794,
    desc: "把600万粉丝ID放进一张合照、用一万行备忘录做一只奔跑的小猫——他是放假会做贼有意思视频的何同学。他对过去和未来保持着同样的好奇心,天马行空的想法打磨成干净利落的投稿。做何同学的粉丝,关注技术进步的同时,更会关心到被数码影响的人类生活本身。",
    face: "http://i0.hdslb.com/bfs/activity-plat/static/af656f929a9b11da0afaad548cc50dcf/F8frVz9MD.jpg",
    // field: "科技",
    // fieldId: 10,
  },
]

数据处理

field,即Up主所属分区,是将分区数组基于 count 数量降序排序后,取排第一的分区名称得到的,具体处理过程如下。

dataset.forEach(d => {
    if (d.tlist !== 0) {
        d.tlist.sort((a, b) => b.count - a.count)
    } else {
        // 机智的党妹 uid: '466272' tlist: 0
        d.tlist = [{ tid: 129, count: 100, name: "时尚" }]
    }
    d.field = d.tlist[0].name
})

由于百大Up里有几个已经翻车凉凉了,所以需要特殊处理下,比如“机智的党妹”删除了所有视频,无从知晓分区数据,且古柳爬取数据时将其 tlist 设置成为 0,所以这里筛选出来后,重新手动设置成“时尚”区,而 count 数量无关紧要,就设置成了100,tid 是b站官方的,参考其他有时尚区的up主数据,copy 过来即可,并且统一以数组格式保存,方便统一用索引取排第一的分区。而其他凉凉的up主数据都还正常,这里就不用额外处理。

有了所有up主的分区数据,接下来统计下各分区的数量。

let fieldCount = {}

const fields = dataset.map(d => d.field)

fields.forEach(d => {
    if (d in fieldCount) {
        fieldCount[d]++
    }
    else {
        fieldCount[d] = 1
    }
})

// console.log(fieldCount)

将统计结果的对象格式通过 Object.entries() 转化成数组格式,其中每一项元素也是数组格式,这里按照分区数量倒叙排序处理,fieldCountArray 后面也会用到绘制图例/legend上。

let fieldCountArray = Object.entries(fieldCount)
fieldCountArray.sort((a, b) => b[1] - a[1])

// console.log(fieldCountArray)

// fieldCountArray
[ 
  ["游戏", 20],
  ["生活", 15],
  ["美食", 11],
  ["知识", 11],
  ["动画", 8],
  ["时尚", 7],
  ["音乐", 6],
  ["鬼畜", 5],
  ["影视", 5],
  ["舞蹈", 4],
  ["科技", 4],
  ["动物圈", 2],
  ["国创", 1],
  ["汽车", 1]
]

最后基于up主的分区属性 field,将其在 fieldCountArray 中的索引作为 fieldId 设置到原始数据集上,这样就能对数据集也按照分区数量降序排序,否则因为本次分区较多、后面颜色也多,如果随机排列,会过于花哨不好识别。

dataset.map(d => d.fieldId = fieldCountArray.findIndex(f => f[0] === d.field))
dataset.sort((a, b) => a.fieldId - b.fieldId)

以上就是数据处理相关操作,知道需要什么,然后处理出对应格式数据,至于中间过程、代码如何写可能每个人有自己的实现方式,这些都问题不大。

画布设置

本次画布的宽高固定,这没什么好说的,基于实际需要什么设置画布都行。

有一点不同的是,这次还设置了 margin,一般用来给绘图区域的上下左右留出相应空间,比如一般左侧有y轴,下方有x轴,这时候就需要给坐标轴、刻度、标签等留出空间,就会相应将 leftbottom 设置大些。

const width = 1400
const height = 700

const margin = {
    top: 100,
    right: 320,
    left: 30
}

const svg = d3.select('#chart')
    .append('svg')
    .attr('width', width)
    .attr('height', height)
    .style('background', '#FEF5E5')

const bounds = svg.append('g')
    .attr('transform', `translate(${margin.left}, ${margin.top})`)

而本次上方留空间给标题,右侧留空间画图例,所以 topright 会大些,而左侧为避免太贴边也空了些区域。

在添加完 SVG 画布后,通过给 SVG 添加一个 g 元素,即 group,然后将其水平向右和垂直向下平移相应像素,这样后续在 g 里绘制的元素其坐标原点就是在图中框选区域的左上角开始,而不是画布的左上角开始。

g 元素可能就是设计师嘴里的“打个组”,实际并不会在页面里渲染出内容,但方便对网页不同区域“打组“进行区分,也方便把一个组内的元素统一平移等操作,是非常有用的元素,后续也会频繁使用。

颜色数据

颜色数组会和 fieldCountArray 里统计的分区一一对应,一开始用的其他配色,听不少人反馈颜色不好看后,改成了这个配色,具体会在番外篇里提到。

const colors = [
    '#5DCD51', '#51CD82', '#51CDC0', '#519BCD', '#515DCD',
    '#8251CD', '#CD519B', '#CD519B', '#CD515D', '#CD8251',
    '#CDC051', '#B6DA81', '#D2E8B0', '#A481DA'
]

添加标题

SVG 里的文字需要通过添加 text 元素来实现,标题也是。这里把标题放置在上方靠左的位置,x/y 坐标很好理解;.text() 里是具体文字内容;字体相关 CSS 样式,如字体大小和权重等需要通过 .style() 进行设置。

const title = svg.append('text')
    .attr('x', margin.left)
    .attr('y', margin.top / 2)
    .attr('dominant-baseline', 'middle')
    .text('2020年度B站百大Up主分区情况')
    .style('font-size', '32px')
    .style('font-weight', 600)

值得注意的是,需要设置 dominant-baseline: middle 将文字水平中轴和 x/y 坐标点对齐。这个属性古柳也是最近看 Fullstack D3 才知道的,现学现用,其他设置的效果如图。同样的垂直中轴对齐坐标点可以通过设置 text-anchor: middle,这个应该用的更频繁,下面就会用上。
链接:https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dominant-baseline

绘制可视化主体图

接下来是前两篇里也多次提到的基于数据绘制元素的操作,想来大家应该很熟悉了。这里矩形宽度 rectWidth 为50px,高度 rectHeight 为80px,矩形上下左右间距为10px,每行最多17个矩形;通过取余取整操作指定每个矩形的坐标就能布局好。

注意这里是在已经水平垂直整体平移过的 bounds 元素里添加而不是在 svg 里添加;并且先添加了一个组 g,以便和其他区域区分开。假如都是直接在 bounds 里添加矩形,因为后续图例里也有矩形,那时候 bounds.selectAll('rect') 选中矩形时可能就会把这里的矩形给选中,就需要再通过设置 class 样式名进行避免。下面添加图例时会演示,但总之多“打个组”并不坏处。

const rectTotalWidth = 60
const rectTotalHeight = 90
const rectPadding = 10
const rectWidth = rectTotalWidth - rectPadding
const rectHeight = rectTotalHeight - rectPadding
const columnNum = 17

const rectsGroup = bounds.append('g')
const rects = rectsGroup.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', (d, i) => i % columnNum * rectTotalWidth)
    .attr('y', (d, i) => Math.floor(i / columnNum) * rectTotalHeight)
    .attr('width', rectWidth)
    .attr('height', rectHeight)
    .attr("fill", d => colors[fieldCountArray.findIndex(item => item[0] === d.field)])

另外 fill 填充矩形颜色时需要根据每个up主的 field 分区数据从 fieldCountArray 里找到索引值,然后从颜色数组 colors 里取出同一位置相对应的颜色即可,主要是 JS 的写法新手不够熟悉的话可能会不好实现。

绑定的数据可以多种格式

这里古柳觉得可能需要单独再讲下,绑定到元素或者说是 D3 选择集 selection 上的数组数据可以是多种格式的,只需要记得 .attr() 里设置属性或 .style() 里设置样式,如果是固定值直接写上即可;如果和数据有关,则通过回调函数指定,其中函数参数 (d, i) 分别是数组里每项元素和元素索引即可。

.selectAll('rect')
.data(dataset)
.attr('x', (d, i) => d * 10)

比如数组里每一项是数字的,d 就是数字;数组是嵌套数组,每一项元素也是数组的 d 就是数组;数组里都是对象的,d 就是对象...然后具体回调函数里进行设置时相应从 d 里取数据即可。

dataset => [0, 1, 2, 3] => d 就是数字
dataset => [['游戏', 21], ['', 10], ['', ]] => d 就是子数组
dataset => [{ name: '', field: '' }, { name: '', field: '' }, { name: '', field: '' }] => d 就是对象

显示up主名字

接着在每个矩形的中心位置添加上up主名字,text-anchordominant-baseline 都设置成 middle,这样文字才能居中显示。当然这里的效果不够好,存在文字重叠的问题,因为只是教程里的小例子,只为了粗略地看下都是那些up主,所以就不过多优化了。

const texts = rectsGroup.selectAll('text')
    .data(dataset)
    .join('text')
    .attr('x', (d, i) => rectWidth / 2 + i % columnNum * rectTotalWidth)
    .attr('y', (d, i) => rectHeight / 2 + Math.floor(i / columnNum) * rectTotalHeight)
    .text(d => d.name)
    .attr('text-anchor', 'middle')
    .attr('dominant-baseline', 'middle')
    .attr('fill', '#000')
    .style('font-size', '9.5px')
    .style('font-weight', 400)
    // .style('writing-mode', 'vertical-rl')

添加图例

接下来在画布右侧绘制图例,以展示各分区的百大up数量。原本右侧预留了320px大小,但因为左侧主图的右侧还有些空间,所以给图例添加 g 元素时水平向左平移到合适位置,具体可以在后续绘制出来后进行调节就好懂了。

const legendPadding = 30

const legendGroup = bounds.append('g')
    .attr('class', 'legend')
    .attr('transform', `translate(${width - margin.right - legendPadding}, 0)`)

同样右侧图例里的矩形左右两侧也预留 legendPadding 空间用于添加分区文字和对应数字。

为了将分区数值大小映射成右侧区域宽度的像素值,需要用到 D3.js 里很有用的比例尺,其实本质就是个函数,线性比例尺就是线性函数,通过 .domain() 设置数据里的最小值和最大值,最小值这里设成0,最大值通过 d3.max() 从嵌套数组 fieldCountArray 里指定元素第二个属性,也就是分区统计数值自动计算得出,再通过 .range() 设置画布上区域的像素值大小,最小值同样为0,最大值为右侧空白减去预留的两侧 legendPadding 大小的数值。注意这里都是以数组的格式传入。(比例尺这里可能还讲的不够清楚,后续文章会再做讲解)

const legendWidthScale = d3.scaleLinear()
    .domain([0, d3.max(fieldCountArray, d => d[1])])
    .range([0, margin.right - legendPadding * 2])

接着为了使图例的整体高度和左侧主图一致,计算出左侧的高度 legendTotalHeight,其实共6行,通过 rectTotalHeightrectPadding 很好计算,这里写的复杂些,但知道在做什么即可;然后 legendBarTotalHeight 就等于图例矩形高度 legendBarHeight 加上下间距的 legendBarPadding

const legendBarPadding = 3
const legendTotalHeight = (Math.floor(dataset.length / columnNum) + 1) * rectTotalHeight - rectPadding
const legendBarTotalHeight = legendTotalHeight / fieldCountArray.length
const legendBarHeight = legendBarTotalHeight - legendBarPadding * 2

最后分别绘制图例的矩形、分区名称、对应数值即可。.selectAll() 里均带上了 class 进行选中元素,尤其文字有两组,所以必须加上,简写成 .selectAll('.legend-label') 也行,但后面必须有这两句设置 .join('text').attr('class', 'legend-label')

另外上面也说了比例尺其实就是个函数,所以直接设置矩形宽度时,直接调用 legendWidthScale() 并传入数据集里每项的分区数值即可。其他属性大多此前讲过了,只需多注意到底要放在什么位置即可。

const legendBar = legendGroup.selectAll('rect.legend-bar')
    .data(fieldCountArray)
    .join('rect')
    .attr('class', 'legend-bar')
    .attr('x', 30)
    .attr('y', (d, i) => legendBarPadding + legendBarTotalHeight * i)
    .attr('width', d => legendWidthScale(d[1]))
    .attr('height', legendBarHeight)
    .attr('fill', (d, i) => colors[i])

const legendLabel = legendGroup.selectAll('text.legend-label')
    .data(fieldCountArray)
    .join('text')
    .attr('class', 'legend-label')
    .attr('x', 30 - 10)
    .attr('y', (d, i) => legendBarTotalHeight * i + legendBarTotalHeight / 2)
    .style('text-anchor', 'end')
    .attr('dominant-baseline', 'middle')
    .text(d => d[0])
    .style('font-size', '14px')

const legendNumber = legendGroup.selectAll('text.legend-number')
    .data(fieldCountArray)
    .join('text')
    .attr('class', 'legend-number')
    .attr('x', d => 35 + legendWidthScale(d[1]))
    .attr('y', (d, i) => legendBarTotalHeight * i + legendBarTotalHeight / 2)
    .attr('dominant-baseline', 'middle')
    .text(d => d[1])
    .style('font-size', 14)
    .attr('fill', '#000')

小结

本文古柳带大家用真实数据集绘制了一个可视化图,借此也讲解了更多 D3.js 的用法。最终效果图可能还有不少问题,比如有群友提到,图例里数值大的可以设成颜色深,小的可以设成颜色浅,这样可能更好。但准备这篇文章已经花了不少时间,想讲的内容都讲到了即可,更进一步的优化就留给大家实现吧。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容