手把手帶你上手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 的用法。最終效果圖可能還有不少問題,比如有羣友提到,圖例裏數值大的可以設成顏色深,小的可以設成顏色淺,這樣可能更好。但準備這篇文章已經花了不少時間,想講的內容都講到了即可,更進一步的優化就留給大家實現吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章