Demo
餅圖/環形圖可以清晰地展示數據之間的比例關係。下面兩個環形圖分別展現了電子遊戲 GTAV 在各個遊戲硬件平臺上和在不同國家的銷售量。
我們的源數據是這樣的:
數據來自 vgcharts
可以看到相對於表格數據,餅圖可以更容易讓人察覺到數據要傳達的意義。
解析
這樣的餅圖,可以分成三個組成部分:環形(Arc)、文字標籤(Label)和連接環形和標籤之間的連線(Line)。
Arc
SVG 的 path 元素可以幫助我們在瀏覽器中渲染出環形,描述形狀的 d 屬性需要使用 d3.arc 來生成。爲了生成一個環形,我們需要四個參數:開始角度(startAngle),結束角度(endAngle),內圓半徑長(innerRadius),外圓半徑長(outerRadius)。當內圓半徑爲0的時候生成的圖形是餅,不爲0的時候則是環形。內外圓的半徑長度我們指定爲自己想要的任意長度,但是開始和結束的角度是根據給出的數據計算出來的,d3.pie 可以根據輸入的數據生成這些角度數據。
假設們有這樣一個數組描述數據:
var data = [1, 1, 2, 3, 5, 8, 13, 21];
d3.pie 可以把這個數據轉化爲扇形的角度數據
var generator = d3.pie().value((d) => d);
var slices = generator(data);
其中 value 是一個取值函數,也就是 accessor,如果單個數據使用對象(object)來描述,那麼這個函數可以指定取 object 裏面的哪個值用於這個扇形的角度。
轉化之後的數據爲這樣的結構:
[
{"data": 1, "value": 1, "index": 6, "startAngle": 6.050474740247008, "endAngle": 6.166830023713296, "padAngle": 0},
{"data": 1, "value": 1, "index": 7, "startAngle": 6.166830023713296, "endAngle": 6.283185307179584, "padAngle": 0},
{"data": 2, "value": 2, "index": 5, "startAngle": 5.817764173314431, "endAngle": 6.050474740247008, "padAngle": 0},
{"data": 3, "value": 3, "index": 4, "startAngle": 5.468698322915565, "endAngle": 5.817764173314431, "padAngle": 0},
{"data": 5, "value": 5, "index": 3, "startAngle": 4.886921905584122, "endAngle": 5.468698322915565, "padAngle": 0},
{"data": 8, "value": 8, "index": 2, "startAngle": 3.956079637853813, "endAngle": 4.886921905584122, "padAngle": 0},
{"data": 13, "value": 13, "index": 1, "startAngle": 2.443460952792061, "endAngle": 3.956079637853813, "padAngle": 0},
{"data": 21, "value": 21, "index": 0, "startAngle": 0.000000000000000, "endAngle": 2.443460952792061, "padAngle": 0}
]
其中 index 指的是該數據在原數組中的 index。
接下來再指定內外徑就可以生成環形了。
var arc = d3.arc().innerRadius([radius]).outerRadius([outerRadius]);
對這個 arc generator 傳入之前獲取的角度屬性即可得到 path 的 d 屬性。
var d = arc(slices[0]);
以上這個例子來源於官方文檔。
Line
連線和標籤的位置有根據不同的設計有不同的佈局方法,這裏採用的方式是在環形之外再計算一個不可見的環形,通過連接兩個環形的圖形畫出一段延伸線,再根據這個環處在圓的左半邊還是右半邊,向外水平延伸一段距離。示意圖如下:
外部的環在最後的實現中是不可見的,這裏畫出來體現開發思路。
連線由三個部分組成,起點、轉折點和終點。起點是可見環的圖心,轉折點是外部不可見環的圖心,終點是從轉折點向外水平延伸一段距離得到的。
判斷環在左半圓還是右半圓可以通過中間角來判斷
var midAngle = (startAngle + endAngle) / 2;
因爲一個整圓的弧度是 2π,所以如果 midAngle 在 [0, π],那麼我們可以判斷環在左半邊圓,如果在 (π, 2π] 這個範圍那麼在右半邊圓。
Label
標籤的位置根據轉折點而定,其中一個細節要注意的是文字的對齊方式 (text-anchor),左半圓環形的標籤應該右對齊,右半邊圓環形應該左對齊。
在某些數據情況下,可能會存在相互遮蓋的標籤,這時候我們需要做設計防重合的設計,一個比較簡單的思路是:如果連續兩個標籤在同一個半圓(都在左或者都在右),並且高度距離只差小於字體高度,那麼把後一個標籤向外延伸上一個標籤的長度。這個設計並沒有在下面的實現中體現,有時間的話讀者可以自行嘗試。
實現
下面是完整源碼實現,數據源存儲在另外的 csv 文件裏面,這裏通過 d3.csv 讀取之後,再在 callback 函數裏面渲染。github 地址在這裏查看。
<!DOCTYPE html>
<html>
<body>
<script src="http://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript">
// Sales number of video game GTAV
// Data visualization in two pie charts
const PLATFORM = {
PS4: 'PS4',
PS3: 'PS3',
PC: 'PC',
XBox360: 'XBOX360',
XBoxOne: 'XBoxOne',
};
const REGION = {
NA: 'NA',
PAL: 'PAL',
JP: 'JP',
OTHER: 'OTHER',
}
function getPie(data = []) {
// console.log('data', data);
const width = 600;
const height = 400;
const outerRadius = 120;
const innerRadius = 80;
const pivotRadius = 160;
// const colorArray = ['red', 'green', 'blue', 'yellow'];
const colorArray = [
'#204A87',
'#EF2928',
'#9ADE00',
'#0084C8'
]
function getMidAngle(d) {
return (d.endAngle + d.startAngle) / 2;
}
const svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height)
svg.append('g').attr('class', 'slices')
svg.append('g').attr('class', 'lines')
svg.append('g').attr('class', 'labels')
const overallTotal = data.reduce((accu, curr) => accu + curr.total || 0, 0);
const formattedOverallTotal = Math.floor(100 * overallTotal) / 100;
svg.append('text').text(`${formattedOverallTotal} m`).attr('transform', `translate(${width / 2}, ${height / 2})`).attr('text-anchor', 'middle')
const getValue = (d) => {
return d.total;
};
const pie = d3.pie().value(getValue);
const slices = pie(data);
const innerArc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius);
const slice = svg.select('.slices').selectAll('path').data(slices);
slice.enter()
.append('path')
.attr('transform', `translate(${width / 2}, ${height / 2})`)
.attr('d', (d, i) => innerArc(slices[i]))
.attr('fill', (d, i) => colorArray[i % (colorArray.length)]);
slice.exit().remove();
const endPoints = [];
const pivotArc = d3.arc().innerRadius(outerRadius).outerRadius(pivotRadius);
const line = svg.select('.lines').selectAll('polyline').data(slices);
line.enter()
.append('polyline')
.attr('points', (d, i) => {
const slice = slices[i];
const innerCentroid = innerArc.centroid(slice);
const x1 = innerCentroid[0] + width / 2;
const y1 = innerCentroid[1] + height / 2;
const pivotPoint = pivotArc.centroid(slice);
const x2 = pivotPoint[0] + width / 2;
const y2 = pivotPoint[1] + height / 2;
const midAngle = getMidAngle(slice);
const x3 = x2 + (midAngle > Math.PI ? -20 : 20);
const y3 = y2;
endPoints[i] = [x3, y3];
return `${x1},${y1} ${x2},${y2} ${x3},${y3}`;
})
.attr('fill', 'none')
.attr('stroke', (d, i) => colorArray[i % (colorArray.length)]);
line.exit().remove();
const label = svg.select('.labels').selectAll('text').data(slices);
label.enter()
.append('text')
.text((d) => {
const value = d.value;
const label = d.data.label;
return `${label}: ${value} m`;
})
.attr('transform', (d, i) => {
const x = endPoints[i][0] + (getMidAngle(d) > Math.PI ? -10 : 10);
const y = endPoints[i][1] + 5;
return `translate(${x}, ${y})`;
})
.attr('text-anchor', (d) => {
const midAngle = getMidAngle(d);
return midAngle > Math.PI ? 'end' : 'start';
});
label.exit().remove();
}
function visualize(data) {
const regionData = Object.keys(REGION).map((region) => {
const total = data.map((datum) => datum.sales[region]).reduce((accu, curr) => accu + curr, 0);
return {
region,
total: Math.floor(total * 100) / 100,
label: region,
};
});
const platformData = data.map((d) => {
let total;
if (d.total) {
total = d.total;
} else {
total = Object.values(d.sales).reduce((accu, curr) => accu + curr, 0);
}
return {
...d,
total: Math.floor(100 * total) / 100,
label: d.platform,
};
});
getPie(platformData, 'pie1');
getPie(regionData, 'pie2');
}
// Need to start a local file server for serving this file due to the security policy of web browser
d3.csv('./GTAV.csv', (row) => {
const dataObject = {
total: parseFloat(row['TOTAL']) || 0,
};
return {
platform: row.platform,
sales: Object.keys(REGION).reduce((accu, region) => {
return {
...accu,
[region]: parseFloat(row[region]),
};
}, dataObject),
};
})
.then(visualize);
</script>
</body>
</html