手把手帶你上手D3.js數據可視化系列(一)

前言

上一篇文章「安利一些不錯的D3.js資源 - 牛衣古柳 2021.06.29」的反響還不錯,記得有新羣友說是主管推給她文章才加過來的,也是很神奇。

一眨眼又一個月沒更新了。其實一直有想寫簡單的 D3.js 入門文章/教程的打算,但總想着要寫就寫的全面細緻些、有趣些、夠通俗易懂些,甚至如果能對標 Daniel ShiffmanProcessing、P5.js 等方面的輸出,能真的讓更多人更順滑地入門 D3.js 可視化就好了。相關閱讀:伴隨 P5.js 入坑創意編程 - 牛衣古柳 2019.06.28

理想很豐滿,現實很骨感。古柳自身水平不夠就不提了,至今沒積累多少案例可以支撐實現上面的目標,還經常因爲一段時間沒接觸 D3.js 就忘個精光,再次拿起來用自己都磕磕絆絆,更何談輸出教程呢?

說起來也很沮喪,古柳一直覺得自己提供不了什麼可視化相關有價值的內容,如果連寫入門教程的事也無法實現的話,真的不知道還能做些什麼。

但一直拖着不行動也不行,仍然心有不甘。縱使無法一上來就輸出較系統全面、夠通俗易懂的教程,很多地方可能無法達到心中的目標,但姑且先行動起來,看看到底能寫出什麼樣的內容再說。優化迭代等有所輸出後再進行也來得及

因而就有了這篇文章,有了這個系列裏的第一篇文章,至於本系列能寫多少,到底會寫成什麼樣,古柳也完全心裏沒數,就讓時間來說明一切吧,另外雖然是奔着初學者也能輕鬆看懂的目標去的,但真的大家看完覺得有什麼感受,古柳也不清楚,所以希望大家多多反饋,後續文章能改進的也繼續改進

正文

基本代碼結構

首先,介紹下代碼結構,id爲"chart"的div元素將用於放後面添加的 SVG 畫布;引入下載到本地的 D3.js 庫(v5.9版本);JS 部分就是本次代碼的重點,且都在 drawChart() 函數裏實現。另外 CSS 樣式主要是爲後續畫布能全屏撐滿不留空白所用。

<!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;
        }

        html,
        body {
            overflow: hidden;
        }</style>
</head>
<body>
    <div id="chart"></div>
    <!-- 可以下載到本地也可以引用線上版本 -->
    <script src="./d3.js"></script>
    <!-- <script src="https://cdn.bootcdn.net/ajax/libs/d3/5.9.7/d3.js"></script> -->
    <script> function drawChart() {
            // code
        }

        drawChart()</script>
</body>

添加 SVG 畫布

以下 JS 代碼都是在 drawChart() 的。

D3.js 進行可視化,可以用矢量圖的 SVG,也可以用標量圖、像素的canvas,因爲古柳 SVG 用的多些,這裏就以此爲例。

可視化畫圖過程簡單說來就是把數據映射成視覺元素,再以特定方式佈局到畫布上。其中視覺元素可以是散點圖裏的圓圈,柱形圖、直方圖裏的矩形,折線圖裏的線條等等;佈局核心是要知道每個元素的x/y座標,可以是自己計算出來,也可以是 D3.js 自帶的許多佈局函數生成的。

接下來以矩形爲例,帶大家看看 D3.js 的一些用法。

首先需要一個 SVG 畫布來放置後續的視覺元素,其實還會放標題/座標軸/圖例等等,這裏可能還用不到,以後會介紹。

通過 d3.select() 選中 id 爲 chart 的 div 元素,這裏的#就是表示id,如果是class就是.,很簡單的 CSS 選擇器用法;

接着通過 append 添加 svg 元素,然後設置其的寬高和背景色,這裏爲了演示方便,設置成瀏覽器網頁窗口高度的全部和寬度的一半,大家也可以撐滿網頁窗口,或者用固定大小如 900*600 等,視自己需求而定即可。

const width = window.innerWidth
const height = window.innerHeight

const svg = d3.select('#chart')
    .append('svg')
    .attr('width', width / 2)
    .attr('height', height)
    .style('background', '#FEF5E5')

其中 window.innerWidthwindow.innerHeight 就是網頁窗口在某一大小打開時的寬高,即圖中紅框部分,並且可以看到畫布佔了一半大小。

畫布設置好後,就可以往裏面添加視覺元素了,就像很多工具/軟件都自帶一些基本圖形元素一樣,SVG 也有 circle/rect/ellipse/polygon/line/path/text 等常用元素,並且每個元素可以設置相應屬性,如位置、寬高、半徑、顏色、描邊、透明度等等(圖片取自 fullstack d3),後續會逐漸介紹,都不復雜。

現在我們要在畫布裏畫一個矩形/rect,同樣用 append 加上元素名即可,然後設置 x/y 位置座標(矩形左上角的座標,而不是中心點的座標)、矩形寬高(數字均爲像素值,如100就是100px)和顏色即可。

需要注意的是:直角座標系原點在網頁窗口左上角,水平向右是x軸正軸,垂直向下是y軸正軸。

svg.append('rect')
    .attr('x', 30)
    .attr('y', 50)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', '#00AEA6')

對應瀏覽器裏生成的 HTML 的內容如下。

<svg width="518.5" height="680" style="background: rgb(254, 245, 229);">
    <rect x="30" y="50" width="50" height="100" fill="#00AEA6"></rect>
</svg>

假如矩形畫在畫布邊緣,超出畫布部分是不可見的。所以如果數據多了,就需要換行顯示,後面會演示如何處理。

svg.append('rect')
    .attr('x', width / 2 - 25)
    .attr('y', 50)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', '#EB5C36')

上面演示瞭如何添加一個元素,但更多時候我們需要根據數據集來添加多個元素,那該如何操作呢?

可能有人想到可以遍歷循環數據來添加元素......其實倒也不是完全不行。

構造簡單數據

這裏用 d3.range(20) 簡單構造個包含0-19數字的數組——[0, 1, 2, ..., 19]——作爲演示的數據集;

const dataset = d3.range(20)
console.log(dataset) // [0, 1, 2, ..., 19]

const colors = ['#00AEA6', '#DB0047', '#F28F00', '#EB5C36', '#242959', '#2965A7']

並且準備了6種顏色,模擬可視化時將某一類別型屬性映射成不同顏色的情況。配色取自於此圖,很好看有沒有,可是古柳靜心挑選的!


因爲顏色數據也是數組,而取數組裏某項元素可以通過索引來進行,比如取第一個顏色就是 colors[0],索引從0開始到數組長度減1結束,即 colors.length - 1,對應顏色是 colors[colors.length - 1],都是比較基礎的 JS。

遍歷數據來添加元素

接着遍歷數據來添加元素就可以這樣實現,當然用 for 循環也可以,這裏簡單着來,採用 forEach 遍歷每項元素,d 依次是0-19每個數字,如果一行排列,可以間隔 70px 排開,d * 70 相當於就是等差數列;由於會超出畫布所以無法顯示全部。

dataset.forEach(d => {
    svg.append('rect')
        .attr('x', 20 + d * 70)
        .attr('y', 20)
        .attr('width', 50)
        .attr('height', 100)
        .attr('fill', colors[d % colors.length])
})

其中每個矩形顏色是用數字對顏色數組長度取餘數後作爲索引值,然後從顏色數組裏取色。數值的取整取餘是很好用的操作,後續也會常常出現,下面是具體取餘的一些例子。

0 % 6 => 0 => colors[0]
1 % 6 => 1 => colors[1]
2 % 6 => 2 => colors[2]
...
5 % 6 => 5 => colors[5]
6 % 6 => 0 => colors[0]
7 % 6 => 1 => colors[1]
...
19 % 6 => 1 => colors[1]

D3.js 基於數據添加元素的方式

回到空白畫布,下面的代碼實現了和上面遍歷循環一樣的效果。

遍歷循環數據來添加元素雖然有時候可行,但一般不會這麼實現,更一般的、更 D3.js 的方式是用這樣一組命令 .selectAll('rect').data(dataset).join('rect') 來基於數據添加元素。

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 20 + d * 70)
    .attr('y', 20)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', d => colors[d % colors.length])

想來很多人第一次接觸這一方式,都會覺得很奇怪吧?要用數據繪製矩形,需要先 selectAll('rect') 選中所有矩形,可現在明明畫布爲空,並不存在 rect 元素,彷彿選了個寂寞?後面 .data(dataset) 就是把數據集綁定到選中的元素上;.join('rect') 是實際添加元素的操作。

接着每個元素的屬性通過回調函數的方式進行設置,其中 d 就是 dataset 裏每一項的數據。固定值的屬性可以直接寫死,無需函數寫法。

這裏暫時不做過多解釋,其實真實原因是古柳也解釋不好,還要牽扯出 enter-update-exit 等一套概念((圖片同樣取自 fullstack d3)),很多人估計入門時就被這些概念繞暈了,所以目前大家只需要記住這是常規操作、很重要,綁定數據進行繪製元素時會頻繁用到,而且記牢這三句即可。

當然大家看網上例子,一定會看到類似下面的寫法,其中 .enter().append() 是以前版本 D3.js 的寫法,用 .join() 替換即可,少寫一句不也挺好;function() {} 也可以用 ES6 的箭頭函數 => 替換,更簡潔方便,推薦大家學些基礎 JS 後也都像上面那樣寫。

const rects = svg.selectAll('rect')
    .data(dataset)
    .enter()
    .append('rect')
    .attr('x', function (d) {
        return 20 + d * 70
    })
    .attr('y', 20)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', function (d) {
        return colors[d % colors.length]
    })

調整佈局,換行顯示

在上面的例子中,矩形都是一行排列,數據一多就會超出畫布,接下來調整下佈局,實現換行顯示的效果。

x 座標的計算公式是 20 + d * 70,這裏希望每一行的最後一個矩形整體都在畫布內,即 x 座標加上矩形寬度要小於畫布寬度。由此可以計算出一行最多放多少個矩形,以 col_num 命名,注意這裏第 n 個元素對於的 d 其實是 n-1,因爲 d 是從0開始的,元素確實從第一個元素開始的。

// 公式
20 + (col_num - 1) * 70 + 50 <= witdh / 2

// 等同於
col_num <= witdh / 2 / 70

計算公式如上,因爲除法有小數,這裏需要向下取個整數,用 Math.floor()parseInt 均可。

const col_num = parseInt(width / 2 / 70)
// const col_num = Math.floor(width / 2 / 70)
console.log(col_num)

算出每列的個數後,就能繼續用上文提到的取整取餘操作來計算每個元素的x/y座標,其本質就是需要知道每個元素在哪一行哪一列。

const dataset = d3.range(50)

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 20 + d % col_num * 70)
    .attr('y', d => 20 + Math.floor(d / col_num) * 120)
    .attr('width', 50)
    .attr('height', 100)
    .attr('fill', d => colors[d % colors.length])

比如每一行x座標等差變化,通過 d % col_num 取餘得到元素在每一行裏的位置並計算到x座標上;每一列y座標等差變化,通過 Math.floor(d / col_num) 取整得到元素在每一列裏的位置並計算到y座標上。這裏初學者如果沒理清的話可以再梳理下。

需要注意的是上面改了 dataset,生成0-49的50條數據,以方便儘量撐滿畫布。所以截止目前,通過運用取餘取整操作,在畫布上較好的繪製出了所有數據。

但如果當數據更多時,超出最大高度又該怎麼辦呢?

也許可以縮小矩形寬高,然後調節間距一步步搞定。(這裏古柳就不調了,主要是引出這個問題)

const dataset = d3.range(100)

const rects = svg.selectAll('rect')
    .data(dataset)
    .join('rect')
    .attr('x', d => 20 + d % col_num * 70)
    .attr('y', d => 20 + Math.floor(d / col_num) * 120)
    .attr('width', 50 / 2)
    .attr('height', 100 / 2)
    .attr('fill', d => colors[d % colors.length])

是否能基於數據大小和畫布寬度來自動計算出每個rect的寬高和間距,然後自動佈局呢?

正好古柳之前啃大西洋手抄本可視化作品源碼時看到了能解決上述問題的實現方式,將在下一篇文章分享給大家,更多 D3.js 內容也將會在下一篇文章繼續展開講解,敬請期待。

相關閱讀:迄今復現過最複雜的可視化作品之「大西洋古抄本」(上) - 牛衣古柳 2021.06.17迄今復現過最複雜的可視化作品之「大西洋古抄本」(下) - 牛衣古柳 2021.06.22

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