美文网首页d3.js
手把手带你上手D3.js数据可视化系列(三)

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

作者: 古柳_Deserts_X | 来源:发表于2021-08-21 08:38 被阅读0次

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

    相关文章

      网友评论

        本文标题:手把手带你上手D3.js数据可视化系列(三)

        本文链接:https://www.haomeiwen.com/subject/nzdxiltx.html