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