前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >手把手带你上手D3.js数据可视化系列(三)手把手带你上手D3.js数据可视化系列(三)

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

作者头像
古柳_DesertsX
发布2021-12-08 17:28:32
2.4K0
发布2021-12-08 17:28:32
举报
文章被收录于专栏:Data Analysis & Viz

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

配套代码和用到的数据都会开源到这个仓库,欢迎大家 Starhttps://github.com/DesertsX/d3-tutorial

前言

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

代码语言:javascript
复制
const dataset = d3.range(30)

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

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

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

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

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

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

这里先看下最终效果图,

基础代码

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

代码语言:javascript
复制
<!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 关键词,这里就不讲解异步操作与同步操作、宏任务与微任务等概念了,大家可自行了解。

代码语言:javascript
复制
async function drawChart() {
    let dataset = await d3.json('2020_bilibili_upzhu.json')
    console.log(dataset[0])
    console.table(dataset)
}

drawChart()

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

代码语言:javascript
复制
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,以便后续使用。

代码语言:javascript
复制
[
  { 
    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 数量降序排序后,取排第一的分区名称得到的,具体处理过程如下。

代码语言:javascript
复制
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主的分区数据,接下来统计下各分区的数量。

代码语言:javascript
复制
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上。

代码语言:javascript
复制
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 设置到原始数据集上,这样就能对数据集也按照分区数量降序排序,否则因为本次分区较多、后面颜色也多,如果随机排列,会过于花哨不好识别。

代码语言:javascript
复制
dataset.map(d => d.fieldId = fieldCountArray.findIndex(f => f[0] === d.field))
dataset.sort((a, b) => a.fieldId - b.fieldId)

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

画布设置

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

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

代码语言:javascript
复制
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 里统计的分区一一对应,一开始用的其他配色,听不少人反馈颜色不好看后,改成了这个配色,具体会在番外篇里提到。

代码语言:javascript
复制
const colors = [
    '#5DCD51', '#51CD82', '#51CDC0', '#519BCD', '#515DCD',
    '#8251CD', '#CD519B', '#CD519B', '#CD515D', '#CD8251',
    '#CDC051', '#B6DA81', '#D2E8B0', '#A481DA'
]

添加标题

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

代码语言:javascript
复制
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 样式名进行避免。下面添加图例时会演示,但总之多“打个组”并不坏处。

代码语言:javascript
复制
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) 分别是数组里每项元素和元素索引即可。

代码语言:javascript
复制
.selectAll('rect')
.data(dataset)
.attr('x', (d, i) => d * 10)

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

代码语言:javascript
复制
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主,所以就不过多优化了。

代码语言:javascript
复制
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 元素时水平向左平移到合适位置,具体可以在后续绘制出来后进行调节就好懂了。

代码语言:javascript
复制
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 大小的数值。注意这里都是以数组的格式传入。(比例尺这里可能还讲的不够清楚,后续文章会再做讲解)

代码语言:javascript
复制
const legendWidthScale = d3.scaleLinear()
    .domain([0, d3.max(fieldCountArray, d => d[1])])
    .range([0, margin.right - legendPadding * 2])

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

代码语言:javascript
复制
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() 并传入数据集里每项的分区数值即可。其他属性大多此前讲过了,只需多注意到底要放在什么位置即可。

代码语言:javascript
复制
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 的用法。最终效果图可能还有不少问题,比如有群友提到,图例里数值大的可以设成颜色深,小的可以设成颜色浅,这样可能更好。但准备这篇文章已经花了不少时间,想讲的内容都讲到了即可,更进一步的优化就留给大家实现吧。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021/8/21 上,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 基础代码
  • 读取数据
  • 数据格式
  • 数据处理
  • 画布设置
  • 颜色数据
  • 添加标题
  • 绘制可视化主体图
  • 绑定的数据可以多种格式
  • 显示up主名字
  • 添加图例
  • 小结
相关产品与服务
数据保险箱
数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档