饼图与环形图
饼图是一种通过扇形块展示数据占比的可视化形式。每一块扇形的角度与其对应的数值成正比,所有扇形的角度之和为 360 度(对应完整圆)。饼图特别适合展示比例关系,比如市场份额、投票结果、宗教分布等。
在学习饼图之前,先理解它的适用场景:饼图适合展示少量分类(通常不超过 7 个)的比例关系;如果分类过多,饼图会变得难以阅读,此时应该考虑使用柱状图等其他形式;饼图能够直观地展示部分占整体的比例,但难以精确比较不同类别之间的具体差异。
核心概念:弧形生成器
与折线图使用 d3.line() 类似,饼图使用 d3.arc() 作为弧形生成器。但这两个生成器的工作方式有本质区别:d3.line() 接收数据数组,返回连接各点的路径字符串;d3.arc() 接收单个数据对象的起始和结束角度,返回对应的扇形路径。
理解这种区别很关键:折线图是一次性处理整个数据数组,而饼图需要对数据中的每一项分别调用弧形生成器。
d3.pie() 函数详解
d3.pie() 是计算扇形角度的核心函数。它接收一个数据数组,自动计算每个数据项对应的角度值。
基础用法
// 市场份额数据
const data = [
{name: '苹果', value: 35},
{name: '三星', value: 25},
{name: '华为', value: 20},
{name: '小米', value: 15},
{name: '其他', value: 5}
];
// 使用 d3.pie() 计算角度
const pieGenerator = d3.pie()
.value(d => d.value) // 指定用于计算角度的数值字段
.sort(null); // 不排序,保持原始顺序
const pieData = pieGenerator(data);
console.log(pieData);
pieData 的输出是一个数组,每个元素包含以下信息:
[
{data: {name: "苹果", value: 35}, value: 35, startAngle: 0, endAngle: 2.199},
{data: {name: "三星", value: 25}, value: 25, startAngle: 2.199, endAngle: 3.89},
// ...
]
每个元素最重要的属性是 startAngle(起始角度)和 endAngle(结束角度),这两个值会被传递给 d3.arc() 来生成扇形路径。
排序控制
通过 sort() 方法可以控制扇形的排列顺序:
// 按数值降序排列(大扇形在图表顶部,顺时针排列)
const pieDescending = d3.pie()
.value(d => d.value)
.sort((a, b) => b.value - a.value);
// 按数值升序排列
const pieAscending = d3.pie()
.value(d => d.value)
.sort((a, b) => a.value - b.value);
// 不排序,保持数据原来的顺序
const pieNoSort = d3.pie()
.value(d => d.value)
.sort(null);
起始角度和间隙
// 设置起始角度(默认从 12 点方向开始)
const pieStartAngle = d3.pie()
.startAngle(0); // 从 0 弧度(12 点)开始
// 旋转 90 度(从 3 点方向开始)
const pieRotated = d3.pie()
.startAngle(Math.PI / 2);
// 设置扇形之间的间隙(单位:弧度)
const pieWithGap = d3.pie()
.padAngle(0.02); // 每个扇形之间 0.02 弧度的间隙
d3.arc() 弧形生成器
d3.arc() 生成器用于根据给定的起始角度和结束角度创建扇形路径。
基础用法
// 外半径和内半径(用于创建环形图)
const outerRadius = 100;
const innerRadius = 50; // 如果为 0,则是实心饼图
const arcGenerator = d3.arc()
.innerRadius(innerRadius) // 内半径
.outerRadius(outerRadius) // 外半径
.cornerRadius(3); // 圆角半径(可选,让扇形边缘更圆润)
// 使用生成器创建扇形路径
const path = arcGenerator({
startAngle: 0,
endAngle: Math.PI / 2 // 四分之一圆
});
// path 输出类似: "M100,0 A100,100 0 0,1 0,100 L50,100 A50,50 0 0,0 100,50 Z"
arcGenerator() 的输入通常是 pieData 数组中的单个元素,因为它已经包含了 startAngle 和 endAngle。
动态半径
有时需要在交互时动态改变扇形的大小,比如鼠标悬停时将扇形稍微向外移动:
// 普通状态的生成器
const normalArc = d3.arc()
.innerRadius(50)
.outerRadius(100);
// 悬停时放大的生成器
const hoverArc = d3.arc()
.innerRadius(50)
.outerRadius(115); // 比平时大
// 通过 attr accessor 动态切换
svg.selectAll('.arc')
.attr('d', d => hoverArc(d));
完整饼图示例
现在让我们创建一个完整的饼图并为之添加交互功能:
// 数据准备
const data = [
{name: '苹果', value: 35, color: '#3498db'},
{name: '三星', value: 25, color: '#e74c3c'},
{name: '华为', value: 20, color: '#2ecc71'},
{name: '小米', value: 15, color: '#f39c12'},
{name: '其他', value: 5, color: '#95a5a6'}
];
// 尺寸设置
const width = 500;
const height = 400;
const radius = Math.min(width, height) / 2 - 40;
// 创建 SVG 画布
const svg = d3.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${width/2},${height/2})`);
// 创建饼图生成器
const pie = d3.pie()
.value(d => d.value)
.sort(null)
.padAngle(0.02);
// 创建弧形生成器
const arc = d3.arc()
.innerRadius(0) // 实心饼图
.outerRadius(radius)
.cornerRadius(4);
// 绘制扇形
const arcs = svg.selectAll('.arc')
.data(pie(data))
.join('g')
.attr('class', 'arc');
arcs.append('path')
.attr('d', arc)
.attr('fill', d => d.data.color)
.attr('stroke', 'white')
.attr('stroke-width', 2)
// 动画效果
.transition()
.duration(1000)
.attrTween('d', function(d) {
// 补间动画:从 0 度逐渐展开
const interpolate = d3.interpolate({startAngle: 0, endAngle: 0}, d);
return function(t) {
return arc(interpolate(t));
};
});
这段代码展示了创建饼图的基本流程:定义尺寸并创建 SVG 画布中心点;创建 pie 生成器来处理数据;创建 arc 生成器来生成扇形路径;用 data() 绑定计算后的角度数据;用 join() 模式添加路径元素。
添加标签
饼图的标签有几种常见的形式,下面逐一介绍:
外部标签(标签线):
// 创建标签位置生成器
const labelArc = d3.arc()
.innerRadius(radius + 20)
.outerRadius(radius + 20);
// 添加标签组
const labels = arcs.append('g')
.attr('class', 'label');
// 添加路径线(从扇形延伸到标签)
labels.append('path')
.attr('d', d => {
// 计算标签位置
const pos = labelArc.centroid(d);
// 计算扇形中点
const midAngle = (d.startAngle + d.endAngle) / 2;
// 决定标签在左边还是右边
const x = midAngle < Math.PI ? -10 : 10;
return `M${arc.centroid(d)}L${pos[0] + x},${pos[1]}L${pos[0] + x * 2},${pos[1]}`;
})
.attr('fill', 'none')
.attr('stroke', '#ccc');
// 添加文本
labels.append('text')
.attr('x', d => labelArc.centroid(d)[0])
.attr('y', d => labelArc.centroid(d)[1])
.attr('dy', '0.35em')
.attr('text-anchor', d => {
const midAngle = (d.startAngle + d.endAngle) / 2;
return midAngle < Math.PI ? 'end' : 'start';
})
.text(d => `${d.data.name}: ${d.data.value}%`)
.attr('font-size', '12px');
内部标签:
// 直接在扇形内部显示文字
arcs.append('text')
.attr('transform', d => `translate(${arc.centroid(d)})`)
.attr('text-anchor', 'middle')
.text(d => d.data.value > 10 ? d.data.name : '') // 只显示数值够大的扇形
.attr('fill', 'white')
.attr('font-size', '14px')
.attr('font-weight', 'bold');
环形图
环形图实际上是饼图的一个变体,只是将内半径设置为大于零的值。环形图的优势在于中心区域可以用于显示额外信息,比如总数、总计等,这在仪表盘类应用中非常常见。
创建环形图
// 环形图关键在于 innerRadius
const arc = d3.arc()
.innerRadius(60) // 大于 0 就是环形
.outerRadius(120)
.cornerRadius(4);
// 中心显示总数
svg.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '-0.2em')
.text('总计')
.attr('font-size', '14px')
.attr('fill', '#666');
svg.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '1em')
.text(d3.sum(data, d => d.value))
.attr('font-size', '24px')
.attr('font-weight', 'bold');
环形图比实心饼图更加现代,因为它减少了中心区域的视觉噪音,同时增加了信息展示的空间。在移动端界面中,环形图尤其受欢迎。
交互式饼图
饼图的交互主要围绕两部分展开:鼠标悬停时的视觉反馈、鼠标点击时的筛选或下钻功能。
悬停动画
// 鼠标悬停时放大扇形
arcs.selectAll('path')
.on('mouseover', function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('transform', function() {
// 计算偏移方向
const centroid = arc.centroid(d);
const angle = Math.atan2(centroid[1], centroid[0]);
const distance = 8;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
return `translate(${x},${y})`;
});
})
.on('mouseout', function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('transform', 'translate(0,0)');
});
这段代码让鼠标悬停的扇形向外偏移,产生一种「弹出」的效果。关键在于计算扇形的几何中心,然后沿着圆心向外方向移动。
点击交互
// 点击时显示详细数据
arcs.selectAll('path')
.on('click', function(event, d) {
// 显示详细弹窗或更新其他图表
showDetail(d.data);
});
function showDetail(dataItem) {
const detailPanel = d3.select('#detail');
detailPanel.html(`
<h3>${dataItem.name}</h3>
<p>市场份额: ${dataItem.value}%</p>
<p>具体数据: 查询中...</p>
`);
}
甜甜圈图(多层环形图)
多层环形图可以展示多个层级的数据,比如年度大类和产品细分的占比关系。
实现方式
const data = [
{category: '电子产品', value: 4500, sub: [
{name: '手机', value: 2000},
{name: '电脑', value: 1500},
{name: '平板', value: 1000}
]},
{category: '服装', value: 2500, sub: [
{name: '上衣', value: 1000},
{name: '裤子', value: 800},
{name: '鞋子', value: 700}
]},
{category: '食品', value: 3000, sub: [
{name: '零食', value: 1500},
{name: '饮料', value: 1000},
{name: '生鲜', value: 500}
]}
];
// 内圈(第一个分类)
const innerArc = d3.arc()
.innerRadius(60)
.outerRadius(100);
const outerArc = d3.arc()
.innerRadius(105)
.outerRadius(145);
// 绘制内圈
svg.selectAll('.inner-arc')
.data(d3.pie().value(d => d.value)(data))
.join('path')
.attr('d', innerArc)
.attr('fill', (d, i) => d3.schemeCategory10[i])
.attr('stroke', 'white');
// 绘制外圈(需要展开子类别)
// 外圈的处理相对复杂,需要为每个子类创建扇形
多层环形图在实现上比单层饼图复杂很多,因为它涉及数据嵌套处理。如果你的数据有明确的层级关系,可以考虑使用旭日图(Sunburst)来展示,D3 同样提供了 d3-hierarchy 模块来支持这种可视化。
总结
饼图是比例数据可视化的经典选择。通过 d3.pie() 计算角度、d3.arc() 生成路径,这两个函数的配合构成了饼图的基础。D3 提供了灵活的栈和补间动画支持,让饼图的呈现效果更加丰富。环形图作为饼图的变体,在保留比例展示能力的同时释放了中心区域添加额外信息的能力,在现代仪表盘中应用广泛。