pin_drop当前位置:知识文库 ❯ 图文

数据可视化:柱状图实现 - 从入门到实践详解

教程简介

柱状图是最常用、最直观的数据可视化图表类型之一,通过矩形柱子的高度(或宽度)来表示数据的大小,非常适合比较不同类别之间的数值差异。本教程将全面讲解柱状图的各类变体实现,包括基础柱状图、分组柱状图、堆叠柱状图、水平柱状图,以及动画效果、交互功能和响应式适配,帮助你掌握柱状图的完整实现能力。

核心概念

柱状图的类型

  1. 基础柱状图:每个类别对应一根柱子,柱高表示数值大小

  2. 分组柱状图:每个类别包含多根柱子,用于比较不同组在同一类别下的表现

  3. 堆叠柱状图:每根柱子由多个子段堆叠而成,展示总量和各部分占比

  4. 水平柱状图(条形图):柱子水平排列,适合类别名称较长或类别较多的场景

  5. 百分比堆叠柱状图:堆叠柱状图的变体,每根柱子高度相同,展示各部分占比

柱状图的设计要素

  • 柱宽与间距:柱宽通常占类别间距的60%-80%,过窄难以比较,过宽显得拥挤

  • 坐标轴:X轴显示类别,Y轴显示数值,需要合理的刻度

  • 网格线:水平网格线辅助数值读取,应尽量淡化

  • 数据标签:直接在柱子上方标注数值,减少读取误差

  • 图例:分组和堆叠柱状图需要图例区分子系列

  • 颜色:不同系列使用不同颜色,同系列保持一致

柱状图的适用场景

  • 比较不同类别的数值大小

  • 展示排名或排序

  • 展示数据随时间的变化(时间作为类别)

  • 展示分组数据的对比

语法与用法

柱状图绘制核心逻辑

代码示例

// 基础柱状图绘制步骤
function drawBarChart(ctx, data, config) {
  const { margin, plotWidth, plotHeight } = config;
  const maxVal = Math.max(...data.map(d => d.value));
  const barWidth = plotWidth / data.length * 0.7;
  const gap = plotWidth / data.length;

  ctx.save();
  ctx.translate(margin.left, margin.top);

  // 1. 绘制网格线和Y轴刻度
  drawGridAndYAxis(ctx, maxVal, plotWidth, plotHeight);

  // 2. 绘制柱子
  data.forEach((d, i) => {
    const barHeight = (d.value / maxVal) * plotHeight;
    const x = i * gap + (gap - barWidth) / 2;
    const y = plotHeight - barHeight;

    ctx.fillStyle = d.color || config.defaultColor;
    ctx.fillRect(x, y, barWidth, barHeight);

    // 3. 绘制X轴标签
    ctx.fillStyle = '#888';
    ctx.textAlign = 'center';
    ctx.fillText(d.label, x + barWidth / 2, plotHeight + 15);
  });

  ctx.restore();
}

代码示例

示例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; }
    .tabs {
      display: flex;
      justify-content: center;
      gap: 10px;
      margin-bottom: 20px;
    }
    .tab-btn {
      padding: 10px 24px;
      border: 2px solid #4e79a7;
      border-radius: 8px;
      background: #fff;
      color: #4e79a7;
      font-size: 14px;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.2s;
    }
    .tab-btn.active {
      background: #4e79a7;
      color: #fff;
    }
    .chart-container {
      background: #fff;
      border-radius: 16px;
      padding: 30px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      max-width: 900px;
      margin: 0 auto;
    }
    canvas { width: 100%; cursor: pointer; }
    .legend {
      display: flex;
      justify-content: center;
      gap: 20px;
      margin-top: 15px;
      flex-wrap: wrap;
    }
    .legend-item {
      display: flex;
      align-items: center;
      gap: 6px;
      font-size: 13px;
      color: #666;
    }
    .legend-color {
      width: 14px;
      height: 14px;
      border-radius: 3px;
    }
  </style>
</head>
<body>
  <h1>柱状图变体演示</h1>
  <div class="tabs">
    <button class="tab-btn active" onclick="switchChart('grouped')">分组柱状图</button>
    <button class="tab-btn" onclick="switchChart('stacked')">堆叠柱状图</button>
    <button class="tab-btn" onclick="switchChart('percent')">百分比堆叠</button>
  </div>
  <div class="chart-container">
    <canvas id="barChart"></canvas>
    <div class="legend" id="legend"></div>
  </div>

  <script>
    const salesData = {
      categories: ['Q1', 'Q2', 'Q3', 'Q4'],
      series: [
        { name: '华东区', color: '#4e79a7', data: [320, 450, 380, 520] },
        { name: '华南区', color: '#f28e2b', data: [280, 350, 420, 480] },
        { name: '华北区', color: '#e15759', data: [200, 280, 350, 400] },
        { name: '西部区', color: '#59a14f', data: [150, 220, 280, 350] }
      ]
    };

    let currentType = 'grouped';
    let animProgress = 0;
    let hoveredSegment = null;
    const canvas = document.getElementById('barChart');
    const dpr = window.devicePixelRatio || 1;
    const displayW = canvas.parentElement.clientWidth - 60;
    const displayH = Math.min(450, displayW * 0.55);
    canvas.width = displayW * dpr;
    canvas.height = displayH * dpr;
    canvas.style.width = displayW + 'px';
    canvas.style.height = displayH + 'px';
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);

    const margin = { top: 30, right: 30, bottom: 50, left: 60 };
    const pw = displayW - margin.left - margin.right;
    const ph = displayH - margin.top - margin.bottom;

    // 存储柱子区域用于交互
    let barSegments = [];

    function renderLegend() {
      const legend = document.getElementById('legend');
      legend.innerHTML = salesData.series.map(s =>
        `<div class="legend-item"><div class="legend-color" style="background:${s.color}"></div>${s.name}</div>`
      ).join('');
    }

    function getMaxValue() {
      if (currentType === 'grouped') {
        return Math.max(...salesData.series.flatMap(s => s.data));
      } else if (currentType === 'stacked') {
        return Math.max(...salesData.categories.map((_, ci) =>
          salesData.series.reduce((sum, s) => sum + s.data[ci], 0)
        ));
      } else {
        return 100; // 百分比
      }
    }

    function niceTicks(maxVal, count) {
      const step = Math.ceil(maxVal / count / 10) * 10;
      const ticks = [];
      for (let v = 0; v <= maxVal + step * 0.5; v += step) {
        ticks.push(Math.round(v));
      }
      return { max: ticks[ticks.length - 1], ticks };
    }

    function drawChart() {
      ctx.clearRect(0, 0, displayW, displayH);
      ctx.save();
      ctx.translate(margin.left, margin.top);

      const rawMax = getMaxValue();
      const { max: maxVal, ticks } = niceTicks(rawMax, 6);
      const catCount = salesData.categories.length;
      const seriesCount = salesData.series.length;

      // 网格线
      ticks.forEach(v => {
        const y = ph - (v / maxVal) * ph;
        ctx.strokeStyle = '#f0f0f0';
        ctx.lineWidth = 1;
        ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();

        ctx.fillStyle = '#999';
        ctx.font = '11px sans-serif';
        ctx.textAlign = 'right';
        ctx.textBaseline = 'middle';
        const label = currentType === 'percent' ? v + '%' : v;
        ctx.fillText(label, -8, y);
      });

      barSegments = [];
      const catGap = pw / catCount;

      salesData.categories.forEach((cat, ci) => {
        const catX = ci * catGap;

        if (currentType === 'grouped') {
          // 分组柱状图
          const groupWidth = catGap * 0.75;
          const barWidth = groupWidth / seriesCount;
          const startX = catX + (catGap - groupWidth) / 2;

          salesData.series.forEach((s, si) => {
            const val = s.data[ci];
            const barH = (val / maxVal) * ph * animProgress;
            const x = startX + si * barWidth;
            const y = ph - barH;

            const isHovered = hoveredSegment &&
              hoveredSegment.ci === ci && hoveredSegment.si === si;

            ctx.fillStyle = isHovered ? s.color + 'dd' : s.color;
            ctx.beginPath();
            if (barH > 4) {
              ctx.moveTo(x + 2, y);
              ctx.lineTo(x + barWidth - 2, y);
              ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + 2);
              ctx.lineTo(x + barWidth, ph);
              ctx.lineTo(x, ph);
              ctx.lineTo(x, y + 2);
              ctx.quadraticCurveTo(x, y, x + 2, y);
            } else {
              ctx.rect(x, y, barWidth, barH);
            }
            ctx.fill();

            barSegments.push({ x, y, w: barWidth, h: barH, ci, si, val, name: s.name, color: s.color });
          });
        } else {
          // 堆叠柱状图
          const barWidth = catGap * 0.5;
          const startX = catX + (catGap - barWidth) / 2;
          let cumY = ph;

          // 计算总量
          const total = salesData.series.reduce((sum, s) => sum + s.data[ci], 0);

          salesData.series.forEach((s, si) => {
            const val = currentType === 'percent'
              ? (s.data[ci] / total) * 100
              : s.data[ci];
            const barH = (val / maxVal) * ph * animProgress;
            const y = cumY - barH;

            const isHovered = hoveredSegment &&
              hoveredSegment.ci === ci && hoveredSegment.si === si;

            ctx.fillStyle = isHovered ? s.color + 'dd' : s.color;
            ctx.fillRect(startX, y, barWidth, barH);

            // 堆叠段边框
            ctx.strokeStyle = '#fff';
            ctx.lineWidth = 1;
            ctx.strokeRect(startX, y, barWidth, barH);

            barSegments.push({ x: startX, y, w: barWidth, h: barH, ci, si, val, name: s.name, color: s.color, total });

            cumY = y;
          });
        }

        // X轴标签
        ctx.fillStyle = '#888';
        ctx.font = '13px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'top';
        ctx.fillText(cat, catX + catGap / 2, ph + 10);
      });

      // Tooltip
      if (hoveredSegment) {
        const seg = hoveredSegment;
        const series = salesData.series[seg.si];
        const text = currentType === 'percent'
          ? `${series.name} ${salesData.categories[seg.ci]}: ${seg.val.toFixed(1)}%`
          : `${series.name} ${salesData.categories[seg.ci]}: ${seg.val}`;
        ctx.font = 'bold 12px sans-serif';
        const tw = ctx.measureText(text).width + 16;
        const tx = seg.x + seg.w / 2;
        const ty = seg.y - 10;

        ctx.fillStyle = 'rgba(44,62,80,0.9)';
        ctx.beginPath();
        ctx.roundRect(tx - tw / 2, ty - 24, tw, 24, 4);
        ctx.fill();

        ctx.fillStyle = '#fff';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(text, tx, ty - 12);
      }

      // 坐标轴
      ctx.strokeStyle = '#ddd';
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();

      ctx.restore();
    }

    function animate() {
      const start = performance.now();
      const duration = 800;
      function step(now) {
        animProgress = Math.min(1, (now - start) / duration);
        animProgress = 1 - Math.pow(1 - animProgress, 3);
        drawChart();
        if (animProgress < 1) requestAnimationFrame(step);
      }
      requestAnimationFrame(step);
    }

    function switchChart(type) {
      currentType = type;
      document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
      event.target.classList.add('active');
      hoveredSegment = null;
      animate();
    }

    // 交互
    canvas.addEventListener('mousemove', (e) => {
      const rect = canvas.getBoundingClientRect();
      const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
      const my = (e.clientY - rect.top) * (displayH / rect.height) - margin.top;

      let found = null;
      for (const seg of barSegments) {
        if (mx >= seg.x && mx <= seg.x + seg.w && my >= seg.y && my <= seg.y + seg.h) {
          found = seg; break;
        }
      }
      if (found !== hoveredSegment) {
        hoveredSegment = found;
        drawChart();
      }
    });

    canvas.addEventListener('mouseleave', () => {
      hoveredSegment = null;
      drawChart();
    });

    renderLegend();
    animate();
  </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: 800px;
    }
    .chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 20px; }
    canvas { width: 100%; cursor: pointer; }
    .sort-btn {
      display: block;
      margin: 15px auto 0;
      padding: 8px 20px;
      border: 2px solid #4e79a7;
      border-radius: 8px;
      background: #fff;
      color: #4e79a7;
      font-size: 14px;
      cursor: pointer;
      transition: all 0.2s;
    }
    .sort-btn:hover { background: #4e79a7; color: #fff; }
  </style>
</head>
<body>
  <div class="chart-container">
    <h2 class="chart-title">编程语言流行度排名</h2>
    <canvas id="hBarChart"></canvas>
    <button class="sort-btn" onclick="toggleSort()">切换排序</button>
  </div>

  <script>
    let langData = [
      { name: 'Python', value: 28.11, color: '#4e79a7' },
      { name: 'JavaScript', value: 21.52, color: '#f28e2b' },
      { name: 'Java', value: 15.68, color: '#e15759' },
      { name: 'C/C++', value: 12.34, color: '#76b7b2' },
      { name: 'C#', value: 7.85, color: '#59a14f' },
      { name: 'TypeScript', value: 5.92, color: '#edc948' },
      { name: 'Go', value: 4.21, color: '#b07aa1' },
      { name: 'Rust', value: 2.87, color: '#ff9da7' },
      { name: 'Swift', value: 2.15, color: '#9c755f' },
      { name: 'Kotlin', value: 1.89, color: '#bab0ac' }
    ];

    let sorted = true;
    let animProgress = 0;
    let targetPositions = [];
    let currentPositions = [];
    let hoveredIndex = -1;

    const canvas = document.getElementById('hBarChart');
    const dpr = window.devicePixelRatio || 1;
    const displayW = canvas.parentElement.clientWidth - 60;
    const displayH = 500;
    canvas.width = displayW * dpr;
    canvas.height = displayH * dpr;
    canvas.style.width = displayW + 'px';
    canvas.style.height = displayH + 'px';
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);

    const margin = { top: 20, right: 80, bottom: 30, left: 100 };
    const pw = displayW - margin.left - margin.right;
    const ph = displayH - margin.top - margin.bottom;

    function calculatePositions() {
      const barH = ph / langData.length * 0.7;
      const gap = ph / langData.length;
      const maxVal = Math.max(...langData.map(d => d.value));

      targetPositions = langData.map((d, i) => ({
        y: margin.top + i * gap + (gap - barH) / 2,
        barW: (d.value / maxVal) * pw,
        barH,
        data: d,
        index: i
      }));
    }

    function drawChart() {
      ctx.clearRect(0, 0, displayW, displayH);
      const maxVal = Math.max(...langData.map(d => d.value));

      // 网格线
      const gridSteps = 5;
      for (let i = 0; i <= gridSteps; i++) {
        const x = margin.left + (pw * i / gridSteps);
        ctx.strokeStyle = '#f0f0f0';
        ctx.lineWidth = 1;
        ctx.beginPath(); ctx.moveTo(x, margin.top); ctx.lineTo(x, margin.top + ph); ctx.stroke();

        ctx.fillStyle = '#999';
        ctx.font = '11px sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText((maxVal * i / gridSteps).toFixed(1) + '%', x, margin.top + ph + 18);
      }

      // 柱子
      currentPositions.forEach((pos, i) => {
        const isHovered = i === hoveredIndex;
        const barW = pos.barW * animProgress;

        // 柱子渐变
        const grad = ctx.createLinearGradient(margin.left, 0, margin.left + barW, 0);
        grad.addColorStop(0, pos.data.color);
        grad.addColorStop(1, pos.data.color + (isHovered ? 'ff' : 'cc'));

        ctx.fillStyle = grad;
        ctx.beginPath();
        const r = Math.min(4, barW / 2);
        if (barW > r * 2) {
          ctx.moveTo(margin.left, pos.y);
          ctx.lineTo(margin.left + barW - r, pos.y);
          ctx.quadraticCurveTo(margin.left + barW, pos.y, margin.left + barW, pos.y + r);
          ctx.lineTo(margin.left + barW, pos.y + pos.barH - r);
          ctx.quadraticCurveTo(margin.left + barW, pos.y + pos.barH, margin.left + barW - r, pos.y + pos.barH);
          ctx.lineTo(margin.left, pos.y + pos.barH);
        } else {
          ctx.rect(margin.left, pos.y, barW, pos.barH);
        }
        ctx.fill();

        // 高亮效果
        if (isHovered) {
          ctx.fillStyle = 'rgba(255,255,255,0.15)';
          ctx.fill();
        }

        // 名称标签
        ctx.fillStyle = isHovered ? '#333' : '#666';
        ctx.font = (isHovered ? 'bold ' : '') + '13px sans-serif';
        ctx.textAlign = 'right';
        ctx.textBaseline = 'middle';
        ctx.fillText(pos.data.name, margin.left - 10, pos.y + pos.barH / 2);

        // 数值标签
        ctx.fillStyle = isHovered ? pos.data.color : '#999';
        ctx.font = (isHovered ? 'bold ' : '') + '12px sans-serif';
        ctx.textAlign = 'left';
        ctx.fillText(pos.data.value.toFixed(2) + '%', margin.left + barW + 8, pos.y + pos.barH / 2);
      });

      // 坐标轴
      ctx.strokeStyle = '#ddd';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(margin.left, margin.top);
      ctx.lineTo(margin.left, margin.top + ph);
      ctx.lineTo(margin.left + pw, margin.top + ph);
      ctx.stroke();
    }

    function animateToTarget() {
      const startPositions = currentPositions.map(p => ({ ...p }));
      const startTime = performance.now();
      const duration = 600;

      function step(now) {
        const t = Math.min(1, (now - startTime) / duration);
        const ease = 1 - Math.pow(1 - t, 3);

        currentPositions = startPositions.map((sp, i) => ({
          ...targetPositions[i],
          y: sp.y + (targetPositions[i].y - sp.y) * ease,
          barW: sp.barW + (targetPositions[i].barW - sp.barW) * ease
        }));

        animProgress = 1;
        drawChart();
        if (t < 1) requestAnimationFrame(step);
      }
      requestAnimationFrame(step);
    }

    function toggleSort() {
      sorted = !sorted;
      if (sorted) {
        langData.sort((a, b) => b.value - a.value);
      } else {
        langData.sort((a, b) => a.name.localeCompare(b.name));
      }
      calculatePositions();
      animateToTarget();
    }

    // 交互
    canvas.addEventListener('mousemove', (e) => {
      const rect = canvas.getBoundingClientRect();
      const my = (e.clientY - rect.top) * (displayH / rect.height);

      let found = -1;
      currentPositions.forEach((pos, i) => {
        if (my >= pos.y && my <= pos.y + pos.barH) found = i;
      });

      if (found !== hoveredIndex) {
        hoveredIndex = found;
        drawChart();
      }
    });

    canvas.addEventListener('mouseleave', () => {
      hoveredIndex = -1;
      drawChart();
    });

    // 初始化
    langData.sort((a, b) => b.value - a.value);
    calculatePositions();
    currentPositions = targetPositions.map(p => ({ ...p, barW: 0 }));

    // 入场动画
    const startTime = performance.now();
    function entrance(now) {
      animProgress = Math.min(1, (now - startTime) / 800);
      animProgress = 1 - Math.pow(1 - animProgress, 3);
      currentPositions.forEach(p => p.barW = targetPositions[currentPositions.indexOf(p)].barW * animProgress);
      drawChart();
      if (animProgress < 1) requestAnimationFrame(entrance);
    }
    requestAnimationFrame(entrance);
  </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;
      padding: 20px;
    }
    .dashboard {
      max-width: 1000px;
      margin: 0 auto;
    }
    h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
    .chart-card {
      background: #fff;
      border-radius: 16px;
      padding: 25px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      margin-bottom: 20px;
    }
    .chart-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 15px;
      flex-wrap: wrap;
      gap: 10px;
    }
    .chart-title { color: #2c3e50; font-size: 18px; }
    .filter-btns { display: flex; gap: 8px; flex-wrap: wrap; }
    .filter-btn {
      padding: 6px 16px;
      border: 1px solid #ddd;
      border-radius: 20px;
      background: #fff;
      color: #666;
      font-size: 12px;
      cursor: pointer;
      transition: all 0.2s;
    }
    .filter-btn.active {
      background: #4e79a7;
      color: #fff;
      border-color: #4e79a7;
    }
    canvas { width: 100%; }
    .stats-row {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
      gap: 15px;
    }
    .stat-card {
      background: #fff;
      border-radius: 12px;
      padding: 18px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.06);
      text-align: center;
    }
    .stat-value { font-size: 28px; font-weight: 700; color: #2c3e50; }
    .stat-label { font-size: 13px; color: #999; margin-top: 4px; }
    .stat-change { font-size: 12px; margin-top: 4px; }
    .stat-up { color: #27ae60; }
    .stat-down { color: #e74c3c; }
  </style>
</head>
<body>
  <div class="dashboard">
    <h1>销售数据分析仪表盘</h1>

    <div class="stats-row" id="statsRow"></div>

    <div class="chart-card">
      <div class="chart-header">
        <span class="chart-title">月度销售趋势</span>
        <div class="filter-btns">
          <button class="filter-btn active" onclick="filterData('all')">全部</button>
          <button class="filter-btn" onclick="filterData('online')">线上</button>
          <button class="filter-btn" onclick="filterData('offline')">线下</button>
        </div>
      </div>
      <canvas id="mainChart"></canvas>
    </div>
  </div>

  <script>
    const monthlyData = [
      { month: '1月',  online: 120, offline: 80 },
      { month: '2月',  online: 95,  offline: 70 },
      { month: '3月',  online: 150, offline: 90 },
      { month: '4月',  online: 180, offline: 110 },
      { month: '5月',  online: 210, offline: 130 },
      { month: '6月',  online: 195, offline: 145 },
      { month: '7月',  online: 230, offline: 160 },
      { month: '8月',  online: 250, offline: 155 },
      { month: '9月',  online: 220, offline: 140 },
      { month: '10月', online: 260, offline: 170 },
      { month: '11月', online: 310, offline: 200 },
      { month: '12月', online: 280, offline: 190 }
    ];

    let currentFilter = 'all';
    let chartData = [];
    let animProgress = 0;
    let hoveredBar = null;
    let selectedIndex = -1;

    const canvas = document.getElementById('mainChart');
    const dpr = window.devicePixelRatio || 1;
    let displayW, displayH, ctx;

    function initCanvas() {
      displayW = canvas.parentElement.clientWidth - 50;
      displayH = Math.min(400, displayW * 0.5);
      canvas.width = displayW * dpr;
      canvas.height = displayH * dpr;
      canvas.style.width = displayW + 'px';
      canvas.style.height = displayH + 'px';
      ctx = canvas.getContext('2d');
      ctx.scale(dpr, dpr);
    }

    const margin = { top: 20, right: 20, bottom: 50, left: 55 };

    function getFilteredData() {
      return monthlyData.map(d => {
        let value;
        if (currentFilter === 'online') value = d.online;
        else if (currentFilter === 'offline') value = d.offline;
        else value = d.online + d.offline;
        return { label: d.month, value, online: d.online, offline: d.offline };
      });
    }

    function updateStats() {
      const data = getFilteredData();
      const total = data.reduce((s, d) => s + d.value, 0);
      const avg = total / data.length;
      const max = Math.max(...data.map(d => d.value));
      const maxMonth = data.find(d => d.value === max).label;
      const onlineTotal = data.reduce((s, d) => s + d.online, 0);
      const offlineTotal = data.reduce((s, d) => s + d.offline, 0);

      document.getElementById('statsRow').innerHTML = `
        <div class="stat-card">
          <div class="stat-value">${total}</div>
          <div class="stat-label">总销售额(万元)</div>
        </div>
        <div class="stat-card">
          <div class="stat-value">${avg.toFixed(1)}</div>
          <div class="stat-label">月均销售额</div>
        </div>
        <div class="stat-card">
          <div class="stat-value">${max}</div>
          <div class="stat-label">最高月份 (${maxMonth})</div>
        </div>
        <div class="stat-card">
          <div class="stat-value">${(onlineTotal / (onlineTotal + offlineTotal) * 100).toFixed(1)}%</div>
          <div class="stat-label">线上占比</div>
          <div class="stat-change stat-up">较去年 +5.2%</div>
        </div>
      `;
    }

    function drawChart() {
      ctx.clearRect(0, 0, displayW, displayH);
      const pw = displayW - margin.left - margin.right;
      const ph = displayH - margin.top - margin.bottom;
      const maxVal = Math.ceil(Math.max(...chartData.map(d => d.value)) / 50) * 50;

      ctx.save();
      ctx.translate(margin.left, margin.top);

      // 网格
      for (let i = 0; i <= 5; i++) {
        const v = maxVal * i / 5;
        const y = ph - (v / maxVal) * ph;
        ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
        ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
        ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
        ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
        ctx.fillText(v, -8, y);
      }

      // 柱子
      const barWidth = pw / chartData.length * 0.6;
      const gap = pw / chartData.length;
      const colors = currentFilter === 'online' ? '#4e79a7' :
                     currentFilter === 'offline' ? '#f28e2b' : '#59a14f';

      chartData.forEach((d, i) => {
        const barH = (d.value / maxVal) * ph * animProgress;
        const x = i * gap + (gap - barWidth) / 2;
        const y = ph - barH;
        const isHovered = hoveredBar === i;
        const isSelected = selectedIndex === i;

        // 渐变
        const grad = ctx.createLinearGradient(x, y, x, ph);
        if (typeof colors === 'string') {
          grad.addColorStop(0, colors);
          grad.addColorStop(1, colors + '88');
        }
        ctx.fillStyle = grad;

        // 圆角
        ctx.beginPath();
        if (barH > 4) {
          ctx.moveTo(x + 3, y);
          ctx.lineTo(x + barWidth - 3, y);
          ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + 3);
          ctx.lineTo(x + barWidth, ph);
          ctx.lineTo(x, ph);
          ctx.lineTo(x, y + 3);
          ctx.quadraticCurveTo(x, y, x + 3, y);
        } else {
          ctx.rect(x, y, barWidth, barH);
        }
        ctx.fill();

        if (isHovered) {
          ctx.fillStyle = 'rgba(255,255,255,0.2)';
          ctx.fill();
        }
        if (isSelected) {
          ctx.strokeStyle = '#333';
          ctx.lineWidth = 2;
          ctx.stroke();
        }

        // 数据标签
        if (isHovered || isSelected) {
          ctx.fillStyle = '#333';
          ctx.font = 'bold 12px sans-serif';
          ctx.textAlign = 'center';
          ctx.fillText(d.value, x + barWidth / 2, y - 8);
        }

        // X轴标签
        ctx.fillStyle = (isHovered || isSelected) ? '#333' : '#888';
        ctx.font = '11px sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText(d.label, x + barWidth / 2, ph + 15);
      });

      // Tooltip
      if (hoveredBar !== null) {
        const d = chartData[hoveredBar];
        const lines = [
          d.label,
          `总计: ${d.value}万元`,
          `线上: ${d.online}万元`,
          `线下: ${d.offline}万元`
        ];
        ctx.font = '12px sans-serif';
        const maxW = Math.max(...lines.map(l => ctx.measureText(l).width));
        const tw = maxW + 20;
        const th = lines.length * 20 + 12;
        const barH = (d.value / maxVal) * ph * animProgress;
        const barX = hoveredBar * gap + (gap - barWidth) / 2;
        let tx = barX + barWidth / 2 - tw / 2;
        tx = Math.max(0, Math.min(pw - tw, tx));
        const ty = ph - barH - th - 10;

        ctx.fillStyle = 'rgba(44,62,80,0.92)';
        ctx.beginPath();
        ctx.roundRect(tx, ty, tw, th, 6);
        ctx.fill();

        lines.forEach((line, li) => {
          ctx.fillStyle = li === 0 ? '#fff' : '#ccc';
          ctx.font = li === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
          ctx.textAlign = 'left';
          ctx.fillText(line, tx + 10, ty + 14 + li * 20);
        });
      }

      // 坐标轴
      ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();

      ctx.restore();
    }

    function filterData(type) {
      currentFilter = type;
      document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
      event.target.classList.add('active');
      chartData = getFilteredData();
      selectedIndex = -1;
      updateStats();
      animateChart();
    }

    function animateChart() {
      const start = performance.now();
      function step(now) {
        animProgress = Math.min(1, (now - start) / 600);
        animProgress = 1 - Math.pow(1 - animProgress, 3);
        drawChart();
        if (animProgress < 1) requestAnimationFrame(step);
      }
      requestAnimationFrame(step);
    }

    // 交互
    canvas.addEventListener('mousemove', (e) => {
      const rect = canvas.getBoundingClientRect();
      const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
      const my = (e.clientY - rect.top) * (displayH / rect.height) - margin.top;
      const pw = displayW - margin.left - margin.right;
      const ph = displayH - margin.top - margin.bottom;
      const gap = pw / chartData.length;
      const barWidth = gap * 0.6;
      const maxVal = Math.ceil(Math.max(...chartData.map(d => d.value)) / 50) * 50;

      let found = null;
      chartData.forEach((d, i) => {
        const x = i * gap + (gap - barWidth) / 2;
        const barH = (d.value / maxVal) * ph;
        const y = ph - barH;
        if (mx >= x && mx <= x + barWidth && my >= y && my <= ph) found = i;
      });

      if (found !== hoveredBar) {
        hoveredBar = found;
        drawChart();
      }
    });

    canvas.addEventListener('click', () => {
      if (hoveredBar !== null) {
        selectedIndex = selectedIndex === hoveredBar ? -1 : hoveredBar;
        drawChart();
      }
    });

    canvas.addEventListener('mouseleave', () => {
      hoveredBar = null;
      drawChart();
    });

    // 响应式
    window.addEventListener('resize', () => {
      initCanvas();
      drawChart();
    });

    // 初始化
    initCanvas();
    chartData = getFilteredData();
    updateStats();
    animateChart();
  </script>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge IE11
Canvas 2D 4+ 3.6+ 4+ 12+ 9+
roundRect 99+ 112+ 16+ 99+ 不支持
Path2D 36+ 31+ 8+ 14+ 不支持
requestAnimationFrame 24+ 23+ 6.1+ 12+ 10+
performance.now() 24+ 15+ 8+ 12+ 10+
devicePixelRatio 4+ 4+ 5+ 12+ 不支持
ResizeObserver 64+ 69+ 13.1+ 16+ 不支持

注意事项与最佳实践

  1. 柱宽比例:柱宽占类别间距的60%-80%为宜,避免过窄或过宽。

  2. Y轴从零开始:柱状图的Y轴必须从零开始,否则会夸大差异造成误导。

  3. 颜色使用:分组柱状图使用分类色阶区分系列,基础柱状图可用同一颜色或渐变。

  4. 堆叠顺序:堆叠柱状图中,将最重要的系列放在底部,便于比较。

  5. 数据标签:当柱子数量较少时,直接在柱子上方标注数值提升可读性。

  6. 动画适度:入场动画时长控制在500-1000ms,避免过长影响体验。

  7. 交互反馈:悬停时提供视觉反馈(高亮、tooltip),点击可选中/筛选。

  8. 响应式:监听窗口resize事件,重新计算Canvas尺寸和重绘图表。

代码规范示例

代码示例

// 好的做法:柱状图配置对象
const barChartConfig = {
  margin: { top: 30, right: 20, bottom: 50, left: 60 },
  barWidthRatio: 0.7,     // 柱宽占类别间距比例
  borderRadius: 4,        // 柱子圆角
  animation: {
    duration: 800,
    easing: 'easeOutCubic'
  },
  colors: {
    grid: '#f0f0f0',
    axis: '#ddd',
    label: '#888',
    tooltip: 'rgba(44,62,80,0.9)'
  },
  yAxis: {
    startFromZero: true,   // Y轴从零开始
    tickCount: 6
  }
};

// 好的做法:数据验证
function validateBarData(data) {
  if (!Array.isArray(data) || data.length === 0) {
    throw new Error('柱状图数据必须是非空数组');
  }
  data.forEach(item => {
    if (typeof item.value !== 'number' || item.value < 0) {
      throw new Error('柱状图数值必须是非负数');
    }
  });
}

// 不好的做法:Y轴不从零开始
// const yMin = Math.min(...data.map(d => d.value)); // 可能造成误导

// 好的做法:Y轴从零开始
// const yMin = 0;

常见问题与解决方案

问题1:分组柱状图柱子太窄

原因:类别数和系列数过多,导致每根柱子宽度不足。

解决方案

  • 减少同时显示的系列数(提供筛选功能)

  • 增加图表宽度

  • 使用堆叠柱状图替代分组柱状图

  • 使用水平柱状图获得更多水平空间

问题2:堆叠柱状图难以比较中间层

原因:堆叠柱状图中只有底层可以直接比较,中间层因基线不同难以比较。

解决方案

  • 对重要系列使用分组柱状图

  • 提供切换分组/堆叠的选项

  • 使用百分比堆叠展示占比

问题3:柱状图动画卡顿

原因:每帧都重新计算和绘制所有元素。

解决方案

  • 使用离屏Canvas缓存静态元素

  • 只重绘变化的部分

  • 减少不必要的save/restore调用

  • 使用will-change: transform提示浏览器优化

问题4:负值柱状图显示异常

原因:负值柱子应该向下延伸,但代码未处理负值情况。

解决方案

代码示例

// 处理正负值
data.forEach(d => {
  const barH = Math.abs(d.value) / maxVal * ph;
  const y = d.value >= 0 ? ph - barH : ph;
  ctx.fillRect(x, y, barWidth, barH);
});

// 绘制零线
const zeroY = ph - (0 - yMin) / (yMax - yMin) * ph;
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(pw, zeroY); ctx.stroke();

总结

本教程全面讲解了柱状图的各类实现:

  1. 基础柱状图:单系列柱状图的核心绘制逻辑

  2. 分组柱状图:多系列并排展示,便于组间对比

  3. 堆叠柱状图:多系列堆叠展示总量和构成

  4. 百分比堆叠:展示各部分占比,消除总量差异

  5. 水平柱状图:适合长标签和排名展示

  6. 动画效果:入场动画、排序过渡动画

  7. 交互功能:悬停提示、点击筛选、数据高亮

  8. 响应式适配:窗口resize时自动调整

柱状图是最基础也最实用的图表类型,掌握其各类变体的实现是数据可视化学习的重要基础。

常见问题

分组柱状图柱子太窄

- 减少同时显示的系列数(提供筛选功能) - 增加图表宽度 - 使用堆叠柱状图替代分组柱状图 - 使用水平柱状图获得更多水平空间

堆叠柱状图难以比较中间层

- 对重要系列使用分组柱状图 - 提供切换分组/堆叠的选项 - 使用百分比堆叠展示占比

柱状图动画卡顿

- 使用离屏Canvas缓存静态元素 - 只重绘变化的部分 - 减少不必要的save/restore调用 - 使用will-change: transform提示浏览器优化

负值柱状图显示异常

负值柱子应该向下延伸,但代码未处理负值情况。

标签: 柱状图 Dashboard 图表 HTTP缓存 Canvas绘图 Bar Chart 请求缓存 数据可视化

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

本文涉及AI创作

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

list快速访问

上一篇: 数据可视化:Canvas图表基础 - 从入门到实践详解 下一篇: 数据可视化:折线图实现 - 从入门到实践详解

poll相关推荐