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();
}

标签定位策略

饼图标签的定位是实现的难点之一,常用策略:

  1. 中心辐射线定位:从圆心沿扇区中线方向延伸,在适当距离放置标签

  2. 引导线定位:从扇区边缘引出折线,连接到外部标签

  3. 内部标签:直接在扇区内放置标签(适合大扇区)

  4. 外部列标签:标签排列在图表两侧

南丁格尔玫瑰图

南丁格尔玫瑰图(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>

浏览器兼容性

特性 Chrome Firefox Safari Edge IE11
Canvas arc 4+ 3.6+ 4+ 12+ 9+
atan2 4+ 3.6+ 4+ 12+ 9+
cos/sin 4+ 3.6+ 4+ 12+ 9+
roundRect 99+ 112+ 16+ 99+ 不支持
devicePixelRatio 4+ 4+ 5+ 12+ 不支持

注意事项与最佳实践

  1. 类别数量控制:饼图类别不超过6-8个,超过时将小类别合并为"其他"。

  2. 避免3D饼图:3D效果扭曲面积感知,容易造成误导,始终使用2D饼图。

  3. 起始角度:从12点方向(-PI/2)开始,符合阅读习惯。

  4. 扇区排序:按数值从大到小排列,最大的扇区从12点方向开始。

  5. 小扇区处理:过小的扇区(<5%)不显示内部标签,使用外部引导线标签。

  6. 颜色区分:相邻扇区使用对比明显的颜色,避免视觉混淆。

  7. 悬停反馈:悬停时扇区向外偏移,提供清晰的视觉反馈。

  8. 数据准确性:确保所有扇区角度之和等于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]再进行比较。

总结

本教程全面讲解了饼图与环形图的各类实现:

  1. 基础饼图:扇区绘制、角度计算、颜色填充

  2. 环形图:内圆裁剪、中心信息展示

  3. 南丁格尔玫瑰图:半径与数值成比例的变体

  4. 多环嵌套图:层级数据的嵌套展示

  5. 扇区交互:悬停偏移、高亮、阴影效果

  6. 标签定位:内部标签、外部引导线标签

  7. 动画效果:扇区展开动画

  8. 数据占比展示:百分比标注、汇总信息

饼图和环形图是展示占比关系的核心工具,掌握其实现对于构建完整的数据可视化应用至关重要。

常见问题

什么是饼图与环形图?

饼图与环形图是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。

如何学习饼图与环形图的实际应用?

教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。

饼图与环形图有哪些注意事项?

常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。

饼图与环形图适合初学者吗?

本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。

饼图与环形图的核心要点是什么?

核心要点包括教程简介等内容,建议按教程顺序逐步学习。

标签: 碰撞检测 图表 Pie Chart 物理引擎 占比分析 数据可视化 饼图 Chart

本文由小确幸生活整理发布,转载请注明出处

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 数据可视化:折线图实现 - 从入门到实践详解 下一篇: 数据可视化:散点图与气泡图 - 从入门到实践详解

poll相关推荐