本系列 D3.js 数据可视化文章是古柳按照自己想写的逻辑来写的,可能和网上的教程都不太一样,至于会写多少篇、写成什么样,古柳也完全心里没数,虽然是奔着初学者也能轻松看懂的目标去的,但真的大家看完觉得有什么感受,古柳也不清楚,所以希望大家多多反馈,后续文章能改进的也继续改进,并且有机会的话基于这个系列再出个视频教程,但那是后话了
。
配套代码和用到的数据都会开源到这个仓库,欢迎大家 Starhttps://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>
很多时候,可视化用到的数据存储在 CSV
或 JSON
文件里,这时直接用 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
,并且数据处理后会新增两个属性 field
和 fieldId
,以便后续使用。
[
{
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轴,这时候就需要给坐标轴、刻度、标签等留出空间,就会相应将 left
和 bottom
设置大些。
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})`)
而本次上方留空间给标题,右侧留空间画图例,所以 top
和 right
会大些,而左侧为避免太贴边也空了些区域。
在添加完 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主名字,text-anchor
和 dominant-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行,通过 rectTotalHeight
和 rectPadding
很好计算,这里写的复杂些,但知道在做什么即可;然后 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
的用法。最终效果图可能还有不少问题,比如有群友提到,图例里数值大的可以设成颜色深,小的可以设成颜色浅,这样可能更好。但准备这篇文章已经花了不少时间,想讲的内容都讲到了即可,更进一步的优化就留给大家实现吧。