本系列 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>
讀取數據
很多時候,可視化用到的數據存儲在 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主名字
接着在每個矩形的中心位置添加上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
的用法。最終效果圖可能還有不少問題,比如有羣友提到,圖例裏數值大的可以設成顏色深,小的可以設成顏色淺,這樣可能更好。但準備這篇文章已經花了不少時間,想講的內容都講到了即可,更進一步的優化就留給大家實現吧。