pin_drop当前位置:知识文库 ❯ 图文
数据可视化:饼图与环形图 - 从入门到实践详解
教程简介
饼图和环形图是展示数据占比关系的经典图表类型,通过扇区的大小直观地呈现各部分占整体的比例。饼图适合展示少量类别的占比分布,环形图则在饼图基础上提供了中心空间用于展示汇总信息。本教程将全面讲解饼图与环形图的实现,包括基础饼图、环形图、南丁格尔玫瑰图、扇区交互、标签定位、动画效果和数据占比展示,帮助你掌握这类图表的完整实现能力。
核心概念
饼图与环形图的区别
饼图的数学原理
饼图的核心是将数据值转换为角度:
代码示例
// 数据值转换为角度
function dataToAngles(data) {
const total = data.reduce((sum, d) => sum + d.value, 0);
let currentAngle = -Math.PI / 2; // 从12点方向开始
return data.map(d => {
const startAngle = currentAngle;
const sweepAngle = (d.value / total) * Math.PI * 2;
currentAngle += sweepAngle;
return { startAngle, endAngle: currentAngle, sweepAngle };
});
}扇区绘制
使用Canvas的arc方法绘制扇区:
代码示例
function drawSector(ctx, cx, cy, radius, startAngle, endAngle, color) {
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}环形图只需在饼图基础上绘制一个内圆:
代码示例
function drawDonut(ctx, cx, cy, outerR, innerR, startAngle, endAngle, color) {
ctx.beginPath();
ctx.arc(cx, cy, outerR, startAngle, endAngle);
ctx.arc(cx, cy, innerR, endAngle, startAngle, true); // 逆时针
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}标签定位策略
饼图标签的定位是实现的难点之一,常用策略:
中心辐射线定位:从圆心沿扇区中线方向延伸,在适当距离放置标签
引导线定位:从扇区边缘引出折线,连接到外部标签
内部标签:直接在扇区内放置标签(适合大扇区)
外部列标签:标签排列在图表两侧
南丁格尔玫瑰图
南丁格尔玫瑰图(Nightingale Rose Chart)将扇区的半径而非角度与数据值成比例:
代码示例
// 玫瑰图中每个扇区的半径与数据值成正比
const radius = (d.value / maxVal) * maxRadius;与饼图的区别:
饼图:所有扇区半径相同,角度表示占比
玫瑰图:所有扇区角度相同,半径表示数值大小
语法与用法
饼图绘制完整流程
代码示例
function drawPieChart(ctx, data, config) {
const { cx, cy, radius } = config;
const total = data.reduce((sum, d) => sum + d.value, 0);
let startAngle = -Math.PI / 2;
data.forEach(d => {
const sweepAngle = (d.value / total) * Math.PI * 2;
const endAngle = startAngle + sweepAngle;
// 绘制扇区
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = d.color;
ctx.fill();
// 绘制标签
const midAngle = startAngle + sweepAngle / 2;
const labelR = radius * 1.2;
const lx = cx + Math.cos(midAngle) * labelR;
const ly = cy + Math.sin(midAngle) * labelR;
ctx.fillStyle = '#333';
ctx.textAlign = midAngle > Math.PI / 2 && midAngle < Math.PI * 1.5 ? 'right' : 'left';
ctx.fillText(`${d.name} ${(d.value / total * 100).toFixed(1)}%`, lx, ly);
startAngle = endAngle;
});
}代码示例
示例1:饼图与环形图对比
以下示例同时展示饼图和环形图,支持悬停交互和动画效果。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>饼图与环形图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
padding: 20px;
}
h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
.charts-row {
display: flex;
gap: 20px;
max-width: 1100px;
margin: 0 auto;
flex-wrap: wrap;
justify-content: center;
}
.chart-card {
background: #fff;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
flex: 1;
min-width: 450px;
}
.chart-card h2 { text-align: center; color: #34495e; margin-bottom: 15px; font-size: 18px; }
canvas { width: 100%; cursor: pointer; }
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin-top: 15px;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #666;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
</style>
</head>
<body>
<h1>饼图与环形图对比</h1>
<div class="charts-row">
<div class="chart-card">
<h2>饼图 - 市场份额</h2>
<canvas id="pieChart"></canvas>
<div class="legend" id="pieLegend"></div>
</div>
<div class="chart-card">
<h2>环形图 - 市场份额</h2>
<canvas id="donutChart"></canvas>
<div class="legend" id="donutLegend"></div>
</div>
</div>
<script>
const marketData = [
{ name: 'Chrome', value: 64.7, color: '#4e79a7' },
{ name: 'Safari', value: 18.8, color: '#f28e2b' },
{ name: 'Firefox', value: 3.2, color: '#e15759' },
{ name: 'Edge', value: 5.3, color: '#76b7b2' },
{ name: 'Opera', value: 2.4, color: '#59a14f' },
{ name: '其他', value: 5.6, color: '#bab0ac' }
];
function createChart(canvasId, isDonut) {
const canvas = document.getElementById(canvasId);
const dpr = window.devicePixelRatio || 1;
const size = Math.min(420, canvas.parentElement.clientWidth - 50);
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cx = size / 2;
const cy = size / 2;
const outerR = size * 0.38;
const innerR = isDonut ? outerR * 0.6 : 0;
const total = marketData.reduce((s, d) => s + d.value, 0);
let hoveredIndex = -1;
let animProgress = 0;
// 计算角度
let startAngle = -Math.PI / 2;
const sectors = marketData.map(d => {
const sweep = (d.value / total) * Math.PI * 2;
const sector = { start: startAngle, end: startAngle + sweep, sweep, data: d };
startAngle += sweep;
return sector;
});
function draw() {
ctx.clearRect(0, 0, size, size);
sectors.forEach((sector, i) => {
const isHovered = i === hoveredIndex;
const animEnd = sector.start + sector.sweep * animProgress;
const offset = isHovered ? 8 : 0;
const midAngle = sector.start + sector.sweep / 2;
const ox = Math.cos(midAngle) * offset;
const oy = Math.sin(midAngle) * offset;
// 阴影
if (isHovered) {
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 12;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
}
ctx.beginPath();
ctx.arc(cx + ox, cy + oy, outerR, sector.start, animEnd);
if (isDonut) {
ctx.arc(cx + ox, cy + oy, innerR, animEnd, sector.start, true);
} else {
ctx.lineTo(cx + ox, cy + oy);
}
ctx.closePath();
ctx.fillStyle = isHovered ? sector.data.color : sector.data.color + 'dd';
ctx.fill();
// 边框
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 标签(动画完成后显示)
if (animProgress > 0.9 && sector.sweep > 0.15) {
const labelR = isDonut ? (outerR + innerR) / 2 : outerR * 0.65;
const lx = cx + ox + Math.cos(midAngle) * labelR;
const ly = cy + oy + Math.sin(midAngle) * labelR;
const pct = (sector.data.value / total * 100).toFixed(1) + '%';
ctx.fillStyle = '#fff';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(pct, lx, ly);
}
});
// 环形图中心文字
if (isDonut && animProgress > 0.9) {
ctx.fillStyle = '#2c3e50';
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('100%', cx, cy - 8);
ctx.fillStyle = '#999';
ctx.font = '12px sans-serif';
ctx.fillText('市场份额', cx, cy + 14);
}
// 外部标签和引导线
if (animProgress > 0.9) {
sectors.forEach((sector, i) => {
if (sector.sweep < 0.15) return; // 太小的扇区不显示外部标签
const midAngle = sector.start + sector.sweep / 2;
const isHovered = i === hoveredIndex;
const offset = isHovered ? 8 : 0;
// 引导线起点
const lineStartR = outerR + 5 + offset;
const sx = cx + Math.cos(midAngle) * lineStartR;
const sy = cy + Math.sin(midAngle) * lineStartR;
// 引导线终点
const lineEndR = outerR + 25 + offset;
const ex = cx + Math.cos(midAngle) * lineEndR;
const ey = cy + Math.sin(midAngle) * lineEndR;
// 水平延伸
const hDir = Math.cos(midAngle) > 0 ? 1 : -1;
const hx = ex + hDir * 15;
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.lineTo(hx, ey);
ctx.stroke();
// 标签文字
ctx.fillStyle = isHovered ? sector.data.color : '#666';
ctx.font = (isHovered ? 'bold ' : '') + '11px sans-serif';
ctx.textAlign = hDir > 0 ? 'left' : 'right';
ctx.textBaseline = 'middle';
ctx.fillText(sector.data.name, hx + hDir * 4, ey);
});
}
}
// 交互
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (size / rect.width) - cx;
const my = (e.clientY - rect.top) * (size / rect.height) - cy;
const dist = Math.sqrt(mx * mx + my * my);
let angle = Math.atan2(my, mx);
let found = -1;
if (dist >= innerR && dist <= outerR + 10) {
sectors.forEach((sector, i) => {
let a = angle;
// 标准化角度到 [-PI/2, 3PI/2]
if (a < -Math.PI / 2) a += Math.PI * 2;
let start = sector.start;
let end = sector.end;
if (start < -Math.PI / 2) { start += Math.PI * 2; end += Math.PI * 2; }
if (a >= start && a < end) found = i;
});
}
if (found !== hoveredIndex) {
hoveredIndex = found;
draw();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredIndex = -1;
draw();
});
// 入场动画
function animate() {
const start = performance.now();
const duration = 1000;
function step(now) {
animProgress = Math.min(1, (now - start) / duration);
animProgress = 1 - Math.pow(1 - animProgress, 3);
draw();
if (animProgress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// 渲染图例
const legendId = isDonut ? 'donutLegend' : 'pieLegend';
document.getElementById(legendId).innerHTML = marketData.map(d =>
`<div class="legend-item"><div class="legend-dot" style="background:${d.color}"></div>${d.name}: ${d.value}%</div>`
).join('');
animate();
}
createChart('pieChart', false);
createChart('donutChart', true);
</script>
</body>
</html>示例2:南丁格尔玫瑰图
以下示例实现南丁格尔玫瑰图,扇区半径与数据值成正比。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>南丁格尔玫瑰图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 700px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 15px; }
canvas { width: 100%; cursor: pointer; }
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-top: 15px;
}
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #666; }
.legend-dot { width: 10px; height: 10px; border-radius: 2px; }
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">各国军费支出(南丁格尔玫瑰图)</h2>
<canvas id="roseChart"></canvas>
<div class="legend" id="roseLegend"></div>
</div>
<script>
const militaryData = [
{ name: '美国', value: 8770, color: '#4e79a7' },
{ name: '中国', value: 2920, color: '#f28e2b' },
{ name: '俄罗斯', value: 864, color: '#e15759' },
{ name: '印度', value: 814, color: '#76b7b2' },
{ name: '沙特', value: 750, color: '#59a14f' },
{ name: '英国', value: 685, color: '#edc948' },
{ name: '德国', value: 560, color: '#b07aa1' },
{ name: '法国', value: 538, color: '#ff9da7' },
{ name: '韩国', value: 463, color: '#9c755f' },
{ name: '日本', value: 460, color: '#bab0ac' }
];
const canvas = document.getElementById('roseChart');
const dpr = window.devicePixelRatio || 1;
const size = Math.min(550, canvas.parentElement.clientWidth - 60);
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cx = size / 2;
const cy = size / 2;
const maxR = size * 0.4;
const innerR = maxR * 0.2;
const maxVal = Math.max(...militaryData.map(d => d.value));
const sectorAngle = (Math.PI * 2) / militaryData.length;
let hoveredIndex = -1;
let animProgress = 0;
function draw() {
ctx.clearRect(0, 0, size, size);
// 参考圆环
[0.25, 0.5, 0.75, 1].forEach(r => {
ctx.beginPath();
ctx.arc(cx, cy, innerR + (maxR - innerR) * r, 0, Math.PI * 2);
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
ctx.stroke();
});
militaryData.forEach((d, i) => {
const startAngle = -Math.PI / 2 + i * sectorAngle;
const endAngle = startAngle + sectorAngle;
const radius = innerR + (d.value / maxVal) * (maxR - innerR) * animProgress;
const isHovered = i === hoveredIndex;
const offset = isHovered ? 5 : 0;
const midAngle = startAngle + sectorAngle / 2;
const ox = Math.cos(midAngle) * offset;
const oy = Math.sin(midAngle) * offset;
if (isHovered) {
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 10;
}
ctx.beginPath();
ctx.arc(cx + ox, cy + oy, radius, startAngle, endAngle);
ctx.arc(cx + ox, cy + oy, innerR, endAngle, startAngle, true);
ctx.closePath();
ctx.fillStyle = isHovered ? d.color : d.color + 'cc';
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 标签
if (animProgress > 0.8) {
const labelR = radius + 15;
const lx = cx + Math.cos(midAngle) * labelR;
const ly = cy + Math.sin(midAngle) * labelR;
const hDir = Math.cos(midAngle) > 0 ? 1 : -1;
ctx.fillStyle = isHovered ? d.color : '#666';
ctx.font = (isHovered ? 'bold ' : '') + '11px sans-serif';
ctx.textAlign = hDir > 0 ? 'left' : 'right';
ctx.textBaseline = 'middle';
ctx.fillText(`${d.name} ${d.value}亿`, lx, ly);
}
});
// 中心文字
if (animProgress > 0.9) {
ctx.fillStyle = '#2c3e50';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('军费支出', cx, cy - 6);
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.fillText('单位: 亿美元', cx, cy + 12);
}
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (size / rect.width) - cx;
const my = (e.clientY - rect.top) * (size / rect.height) - cy;
const dist = Math.sqrt(mx * mx + my * my);
let angle = Math.atan2(my, mx) + Math.PI / 2;
if (angle < 0) angle += Math.PI * 2;
let found = -1;
if (dist >= innerR && dist <= maxR + 10) {
const idx = Math.floor(angle / sectorAngle);
if (idx >= 0 && idx < militaryData.length) {
const d = militaryData[idx];
const r = innerR + (d.value / maxVal) * (maxR - innerR);
if (dist <= r + 5) found = idx;
}
}
if (found !== hoveredIndex) {
hoveredIndex = found;
draw();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredIndex = -1;
draw();
});
// 动画
const startTime = performance.now();
function animate(now) {
animProgress = Math.min(1, (now - startTime) / 1000);
animProgress = 1 - Math.pow(1 - animProgress, 3);
draw();
if (animProgress < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// 图例
document.getElementById('roseLegend').innerHTML = militaryData.map(d =>
`<div class="legend-item"><div class="legend-dot" style="background:${d.color}"></div>${d.name}</div>`
).join('');
</script>
</body>
</html>示例3:交互式多环图
以下示例实现一个多环嵌套的环形图,展示层级数据的占比关系。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多环嵌套环形图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 750px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 15px; }
canvas { width: 100%; cursor: pointer; }
.info-panel {
margin-top: 15px;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 8px;
font-size: 13px;
color: #666;
text-align: center;
min-height: 40px;
}
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">公司支出结构分析</h2>
<canvas id="multiRingChart"></canvas>
<div class="info-panel" id="infoPanel">悬停扇区查看详情</div>
</div>
<script>
const ringData = [
{
name: '大类',
items: [
{ name: '研发', value: 40, color: '#4e79a7' },
{ name: '市场', value: 25, color: '#f28e2b' },
{ name: '运营', value: 20, color: '#e15759' },
{ name: '行政', value: 15, color: '#59a14f' }
]
},
{
name: '子类',
items: [
{ name: '前端', value: 15, color: '#4e79a7' },
{ name: '后端', value: 15, color: '#5a8db8' },
{ name: 'AI', value: 10, color: '#6ca3c9' },
{ name: '广告', value: 12, color: '#f28e2b' },
{ name: '品牌', value: 8, color: '#f5a04d' },
{ name: '活动', value: 5, color: '#f8b570' },
{ name: '客服', value: 10, color: '#e15759' },
{ name: '物流', value: 10, color: '#e8797f' },
{ name: '人力', value: 8, color: '#59a14f' },
{ name: '办公', value: 7, color: '#72b860' }
]
}
];
const canvas = document.getElementById('multiRingChart');
const dpr = window.devicePixelRatio || 1;
const size = Math.min(550, canvas.parentElement.clientWidth - 60);
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cx = size / 2;
const cy = size / 2;
const ringWidth = 35;
const gap = 8;
const innerR = size * 0.15;
let hoveredItem = null;
let animProgress = 0;
// 预计算扇区
const rings = ringData.map((ring, ri) => {
const outerR = innerR + (ringWidth + gap) * (ri + 1);
const rInner = outerR - ringWidth;
const total = ring.items.reduce((s, d) => s + d.value, 0);
let startAngle = -Math.PI / 2;
const sectors = ring.items.map(item => {
const sweep = (item.value / total) * Math.PI * 2;
const sector = { start: startAngle, end: startAngle + sweep, sweep, item, ringIndex: ri };
startAngle += sweep;
return sector;
});
return { outerR, innerR: rInner, sectors, total };
});
function draw() {
ctx.clearRect(0, 0, size, size);
rings.forEach((ring, ri) => {
ring.sectors.forEach((sector, si) => {
const isHovered = hoveredItem && hoveredItem.ringIndex === ri &&
hoveredItem.sectorIndex === si;
const animEnd = sector.start + sector.sweep * animProgress;
const midAngle = sector.start + sector.sweep / 2;
const offset = isHovered ? 4 : 0;
const ox = Math.cos(midAngle) * offset;
const oy = Math.sin(midAngle) * offset;
if (isHovered) {
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 8;
}
ctx.beginPath();
ctx.arc(cx + ox, cy + oy, ring.outerR, sector.start, animEnd);
ctx.arc(cx + ox, cy + oy, ring.innerR, animEnd, sector.start, true);
ctx.closePath();
ctx.fillStyle = isHovered ? sector.item.color : sector.item.color + 'cc';
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
});
});
// 中心文字
if (animProgress > 0.9) {
ctx.fillStyle = '#2c3e50';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('支出', cx, cy - 6);
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.fillText('结构分析', cx, cy + 12);
}
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (size / rect.width) - cx;
const my = (e.clientY - rect.top) * (size / rect.height) - cy;
const dist = Math.sqrt(mx * mx + my * my);
let angle = Math.atan2(my, mx);
let found = null;
rings.forEach((ring, ri) => {
if (dist >= ring.innerR && dist <= ring.outerR) {
ring.sectors.forEach((sector, si) => {
let a = angle;
if (a < -Math.PI / 2) a += Math.PI * 2;
let start = sector.start;
let end = sector.end;
if (start < -Math.PI / 2) { start += Math.PI * 2; end += Math.PI * 2; }
if (a >= start && a < end) {
found = { ringIndex: ri, sectorIndex: si, sector };
}
});
}
});
if (found !== hoveredItem) {
hoveredItem = found;
draw();
if (found) {
const item = found.sector.item;
const total = rings[found.ringIndex].total;
const pct = (item.value / total * 100).toFixed(1);
document.getElementById('infoPanel').innerHTML =
`<strong style="color:${item.color}">${item.name}</strong>: ${item.value}万元 (${pct}%) — ${ringData[found.ringIndex].name}`;
} else {
document.getElementById('infoPanel').textContent = '悬停扇区查看详情';
}
}
});
canvas.addEventListener('mouseleave', () => {
hoveredItem = null;
draw();
document.getElementById('infoPanel').textContent = '悬停扇区查看详情';
});
// 动画
const startTime = performance.now();
function animate(now) {
animProgress = Math.min(1, (now - startTime) / 1000);
animProgress = 1 - Math.pow(1 - animProgress, 3);
draw();
if (animProgress < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
类别数量控制:饼图类别不超过6-8个,超过时将小类别合并为"其他"。
避免3D饼图:3D效果扭曲面积感知,容易造成误导,始终使用2D饼图。
起始角度:从12点方向(-PI/2)开始,符合阅读习惯。
扇区排序:按数值从大到小排列,最大的扇区从12点方向开始。
小扇区处理:过小的扇区(<5%)不显示内部标签,使用外部引导线标签。
颜色区分:相邻扇区使用对比明显的颜色,避免视觉混淆。
悬停反馈:悬停时扇区向外偏移,提供清晰的视觉反馈。
数据准确性:确保所有扇区角度之和等于360度,避免浮点误差。
代码规范示例
代码示例
// 好的做法:饼图配置对象
const pieChartConfig = {
startAngle: -Math.PI / 2,
innerRadius: 0, // 0为饼图,>0为环形图
outerRadius: 180,
hoverOffset: 8,
borderWidth: 2,
borderColor: '#fff',
label: {
showInside: true, // 大扇区内部显示
insideThreshold: 0.1, // 占比>10%的扇区内部显示
showOutside: true, // 小扇区外部显示
leaderLine: true // 引导线
},
animation: {
duration: 1000,
easing: 'easeOutCubic'
}
};
// 好的做法:数据预处理
function preprocessPieData(data, maxSlices = 8) {
// 按值排序
const sorted = [...data].sort((a, b) => b.value - a.value);
// 合并小类别
if (sorted.length > maxSlices) {
const top = sorted.slice(0, maxSlices - 1);
const otherValue = sorted.slice(maxSlices - 1).reduce((s, d) => s + d.value, 0);
top.push({ name: '其他', value: otherValue, color: '#bab0ac' });
return top;
}
return sorted;
}常见问题与解决方案
问题1:饼图扇区角度计算有误差
原因:浮点数精度问题导致角度之和不为2PI。
解决方案:最后一个扇区的结束角度强制设为起始角度+2PI。
问题2:小扇区标签重叠
原因:相邻小扇区的标签位置太近。
解决方案:实现标签防重叠算法,检测碰撞后调整位置。
问题3:环形图中心文字被遮挡
原因:内半径太小,中心文字与扇区重叠。
解决方案:确保内半径至少为外半径的50%,文字大小适配中心空间。
问题4:扇区命中检测不准确
原因:角度范围跨越-PI/2边界时判断出错。
解决方案:统一角度范围到[0, 2PI]再进行比较。
总结
本教程全面讲解了饼图与环形图的各类实现:
基础饼图:扇区绘制、角度计算、颜色填充
环形图:内圆裁剪、中心信息展示
南丁格尔玫瑰图:半径与数值成比例的变体
多环嵌套图:层级数据的嵌套展示
扇区交互:悬停偏移、高亮、阴影效果
标签定位:内部标签、外部引导线标签
动画效果:扇区展开动画
数据占比展示:百分比标注、汇总信息
饼图和环形图是展示占比关系的核心工具,掌握其实现对于构建完整的数据可视化应用至关重要。
常见问题
什么是饼图与环形图?
饼图与环形图是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。
如何学习饼图与环形图的实际应用?
教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。
饼图与环形图有哪些注意事项?
常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。
饼图与环形图适合初学者吗?
本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。
饼图与环形图的核心要点是什么?
核心要点包括教程简介等内容,建议按教程顺序逐步学习。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别