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

数据可视化:Canvas图表基础 - 从入门到实践详解

教程简介

Canvas是HTML5提供的2D绘图API,通过JavaScript在画布上逐像素绘制图形。与SVG的保留模式不同,Canvas采用立即模式——绘制后不保留图形对象,这使得它在处理大量数据点时具有显著的性能优势。本教程将系统讲解Canvas绘图API、坐标映射、刻度计算、图例绘制、交互提示(tooltip)实现,以及Canvas与SVG的选型策略,帮助你掌握使用Canvas构建高性能图表的核心能力。

核心概念

Canvas绘图API

Canvas 2D上下文(CanvasRenderingContext2D)提供了丰富的绘图方法:

基础图形绘制:

方法 用途 示例
fillRect() 填充矩形 ctx.fillRect(x, y, w, h)
strokeRect() 描边矩形 ctx.strokeRect(x, y, w, h)
clearRect() 清除区域 ctx.clearRect(0, 0, w, h)
fillText() 填充文字 ctx.fillText('text', x, y)
strokeText() 描边文字 ctx.strokeText('text', x, y)

路径绘制:

方法 用途
beginPath() 开始新路径
closePath() 闭合路径
moveTo(x, y) 移动画笔
lineTo(x, y) 直线到点
arc(x, y, r, start, end) 绘制弧线
arcTo(x1, y1, x2, y2, r) 绘制圆角
quadraticCurveTo(cpx, cpy, x, y) 二次贝塞尔曲线
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 三次贝塞尔曲线
fill() 填充路径
stroke() 描边路径

样式属性:

属性 用途 示例
fillStyle 填充颜色 '#4CAF50' / gradient / pattern
strokeStyle 描边颜色 '#333'
lineWidth 线宽 2
lineCap 线端样式 'butt' / 'round' / 'square'
lineJoin 线连接样式 'miter' / 'round' / 'bevel'
font 字体 '14px sans-serif'
textAlign 文本对齐 'left' / 'center' / 'right'
textBaseline 文本基线 'top' / 'middle' / 'bottom'
globalAlpha 全局透明度 0.0 ~ 1.0
shadowBlur 阴影模糊 10
shadowColor 阴影颜色 'rgba(0,0,0,0.3)'

坐标映射

数据可视化中的核心问题之一是将数据值映射到画布像素坐标。

数据空间到像素空间的映射:

代码示例

// 线性映射函数
function linearScale(value, domainMin, domainMax, rangeMin, rangeMax) {
  return rangeMin + (value - domainMin) / (domainMax - domainMin) * (rangeMax - rangeMin);
}

// 使用示例:将数据值42映射到画布Y坐标
// 数据范围: 0-100, 画布Y范围: 300(底) - 50(顶)
const y = linearScale(42, 0, 100, 300, 50); // 结果: 195

反向映射(像素到数据,用于交互):

代码示例

function invertScale(pixel, domainMin, domainMax, rangeMin, rangeMax) {
  return domainMin + (pixel - rangeMin) / (rangeMax - rangeMin) * (domainMax - domainMin);
}

常用映射类型:

  • 线性映射:等间距映射,适用于大多数场景

  • 对数映射:用于数据跨度很大的场景(如1到1000000)

  • 时间映射:将日期映射到像素位置

刻度计算

合理的刻度计算是图表可读性的关键:

代码示例

function calculateTicks(min, max, tickCount) {
  const range = max - min;
  const roughStep = range / tickCount;

  // 计算美观的步长(1, 2, 5的倍数)
  const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
  const residual = roughStep / magnitude;

  let niceStep;
  if (residual <= 1.5) niceStep = 1 * magnitude;
  else if (residual <= 3) niceStep = 2 * magnitude;
  else if (residual <= 7) niceStep = 5 * magnitude;
  else niceStep = 10 * magnitude;

  const niceMin = Math.floor(min / niceStep) * niceStep;
  const niceMax = Math.ceil(max / niceStep) * niceStep;

  const ticks = [];
  for (let v = niceMin; v <= niceMax + niceStep * 0.5; v += niceStep) {
    ticks.push(Math.round(v * 1e10) / 1e10); // 消除浮点误差
  }

  return { min: niceMin, max: niceMax, step: niceStep, ticks };
}

图例绘制

Canvas中图例需要手动绘制:

代码示例

function drawLegend(ctx, series, x, y) {
  const itemHeight = 25;
  const colorBoxSize = 14;

  series.forEach((s, i) => {
    const iy = y + i * itemHeight;

    // 色块
    ctx.fillStyle = s.color;
    ctx.fillRect(x, iy, colorBoxSize, colorBoxSize);

    // 标签
    ctx.fillStyle = '#666';
    ctx.font = '13px sans-serif';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.fillText(s.name, x + colorBoxSize + 8, iy + colorBoxSize / 2);
  });
}

交互提示(tooltip)

Canvas没有DOM事件,需要手动实现命中检测和tooltip:

代码示例

// 命中检测:判断鼠标是否在某个柱子区域内
function hitTest(mx, my, bars) {
  for (const bar of bars) {
    if (mx >= bar.x && mx <= bar.x + bar.width &&
        my >= bar.y && my <= bar.y + bar.height) {
      return bar;
    }
  }
  return null;
}

// 绘制tooltip
function drawTooltip(ctx, x, y, content) {
  ctx.save();
  ctx.font = '12px sans-serif';
  const metrics = ctx.measureText(content);
  const padding = 8;
  const tw = metrics.width + padding * 2;
  const th = 24;

  // 背景
  ctx.fillStyle = 'rgba(0,0,0,0.8)';
  ctx.beginPath();
  ctx.roundRect(x - tw / 2, y - th - 5, tw, th, 4);
  ctx.fill();

  // 文字
  ctx.fillStyle = '#fff';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(content, x, y - th / 2 - 5);
  ctx.restore();
}

Canvas图表的优缺点

优势:

  • 高性能渲染,适合大数据量(>1000个元素)

  • 像素级控制,适合复杂绘图

  • 内存占用低,不需要维护DOM树

  • 适合实时渲染和动画

  • 输出为位图,方便截图和保存

劣势:

  • 无DOM结构,交互需要手动实现命中检测

  • 缩放会模糊(非矢量)

  • 不支持CSS样式

  • 无障碍访问困难

  • 文本渲染质量不如SVG

Canvas vs SVG选型

考量因素 选SVG 选Canvas
数据量 < 1000个元素 > 1000个元素
交互复杂度 需要单个元素交互 整体交互即可
缩放需求 需要无损缩放 固定尺寸即可
动画复杂度 简单过渡动画 复杂实时动画
无障碍要求 需要ARIA支持 不要求
文本渲染 大量文本 少量文本
开发效率 DOM操作直观 需手动管理状态

语法与用法

Canvas基本设置

代码示例

// 获取Canvas和上下文
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

// 高DPI适配
function setupCanvas(canvas, width, height) {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = width * dpr;
  canvas.height = height * dpr;
  canvas.style.width = width + 'px';
  canvas.style.height = height + 'px';
  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  return ctx;
}

绘制流程

代码示例

function drawChart(canvas, data) {
  const ctx = canvas.getContext('2d');
  const margin = { top: 30, right: 20, bottom: 40, left: 50 };
  const width = canvas.width - margin.left - margin.right;
  const height = canvas.height - margin.top - margin.bottom;

  // 1. 清除画布
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 2. 保存状态,移动原点
  ctx.save();
  ctx.translate(margin.left, margin.top);

  // 3. 绘制背景元素(网格、坐标轴)
  drawGrid(ctx, width, height);
  drawAxes(ctx, width, height);

  // 4. 绘制数据
  drawData(ctx, data, width, height);

  // 5. 绘制前景元素(标签、图例)
  drawLabels(ctx, data, width, height);
  drawLegend(ctx, data);

  // 6. 恢复状态
  ctx.restore();
}

代码示例

示例1:Canvas柱状图(含坐标映射与刻度计算)

以下示例实现一个完整的Canvas柱状图,包含自动刻度计算、坐标映射、网格线、动画和交互提示。

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas柱状图</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;
      align-items: center;
      min-height: 100vh;
      padding: 20px;
    }
    .chart-wrapper {
      background: #fff;
      border-radius: 16px;
      padding: 30px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      width: 100%;
      max-width: 900px;
    }
    .chart-title {
      text-align: center;
      color: #2c3e50;
      font-size: 20px;
      margin-bottom: 20px;
    }
    canvas {
      width: 100%;
      cursor: crosshair;
    }
  </style>
</head>
<body>
  <div class="chart-wrapper">
    <h2 class="chart-title">Canvas柱状图 - 城市人口数据</h2>
    <canvas id="barChart"></canvas>
  </div>

  <script>
    const cityData = [
      { name: '北京', value: 2189, color: '#4e79a7' },
      { name: '上海', value: 2487, color: '#f28e2b' },
      { name: '广州', value: 1868, color: '#e15759' },
      { name: '深圳', value: 1756, color: '#76b7b2' },
      { name: '成都', value: 2094, color: '#59a14f' },
      { name: '杭州', value: 1237, color: '#edc948' },
      { name: '武汉', value: 1365, color: '#b07aa1' },
      { name: '南京', value: 942, color: '#ff9da7' }
    ];

    // 刻度计算
    function niceNum(range, round) {
      const exponent = Math.floor(Math.log10(range));
      const fraction = range / Math.pow(10, exponent);
      let niceFraction;
      if (round) {
        if (fraction < 1.5) niceFraction = 1;
        else if (fraction < 3) niceFraction = 2;
        else if (fraction < 7) niceFraction = 5;
        else niceFraction = 10;
      } else {
        if (fraction <= 1) niceFraction = 1;
        else if (fraction <= 2) niceFraction = 2;
        else if (fraction <= 5) niceFraction = 5;
        else niceFraction = 10;
      }
      return niceFraction * Math.pow(10, exponent);
    }

    function calculateNiceTicks(minVal, maxVal, maxTicks) {
      const range = niceNum(maxVal - minVal, false);
      const step = niceNum(range / (maxTicks - 1), true);
      const niceMin = Math.floor(minVal / step) * step;
      const niceMax = Math.ceil(maxVal / step) * step;
      const ticks = [];
      for (let v = niceMin; v <= niceMax + step * 0.5; v += step) {
        ticks.push(Math.round(v * 100) / 100);
      }
      return { min: niceMin, max: niceMax, step, ticks };
    }

    // Canvas高DPI适配
    function setupHiDPI(canvas, w, h) {
      const dpr = window.devicePixelRatio || 1;
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      canvas.style.width = w + 'px';
      canvas.style.height = h + 'px';
      const ctx = canvas.getContext('2d');
      ctx.scale(dpr, dpr);
      return { ctx, dpr };
    }

    class BarChart {
      constructor(canvasId, data) {
        this.canvas = document.getElementById(canvasId);
        this.data = data;
        this.margin = { top: 30, right: 30, bottom: 60, left: 70 };
        this.displayWidth = this.canvas.parentElement.clientWidth - 60;
        this.displayHeight = Math.min(450, this.displayWidth * 0.55);
        this.bars = [];
        this.hoveredBar = null;
        this.animProgress = 0;

        const { ctx } = setupHiDPI(this.canvas, this.displayWidth, this.displayHeight);
        this.ctx = ctx;

        this.setupEvents();
        this.animate();
      }

      get plotWidth() { return this.displayWidth - this.margin.left - this.margin.right; }
      get plotHeight() { return this.displayHeight - this.margin.top - this.margin.bottom; }

      setupEvents() {
        this.canvas.addEventListener('mousemove', (e) => {
          const rect = this.canvas.getBoundingClientRect();
          const scaleX = this.displayWidth / rect.width;
          const scaleY = this.displayHeight / rect.height;
          const mx = (e.clientX - rect.left) * scaleX - this.margin.left;
          const my = (e.clientY - rect.top) * scaleY - this.margin.top;

          let found = null;
          for (const bar of this.bars) {
            if (mx >= bar.x && mx <= bar.x + bar.w && my >= bar.y && my <= bar.y + bar.h) {
              found = bar; break;
            }
          }
          if (found !== this.hoveredBar) {
            this.hoveredBar = found;
            this.draw();
          }
        });

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

      animate() {
        const start = performance.now();
        const duration = 800;
        const step = (now) => {
          this.animProgress = Math.min(1, (now - start) / duration);
          // 缓动函数
          this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
          this.draw();
          if (this.animProgress < 1) requestAnimationFrame(step);
        };
        requestAnimationFrame(step);
      }

      draw() {
        const ctx = this.ctx;
        const m = this.margin;
        const pw = this.plotWidth;
        const ph = this.plotHeight;

        ctx.clearRect(0, 0, this.displayWidth, this.displayHeight);

        // 计算刻度
        const maxVal = Math.max(...this.data.map(d => d.value));
        const tickInfo = calculateNiceTicks(0, maxVal, 6);

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

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

          // Y轴刻度标签
          ctx.fillStyle = '#999';
          ctx.font = '12px sans-serif';
          ctx.textAlign = 'right';
          ctx.textBaseline = 'middle';
          ctx.fillText(v.toLocaleString(), -10, y);
        });

        // 柱子
        this.bars = [];
        const barWidth = pw / this.data.length * 0.6;
        const gap = pw / this.data.length;

        this.data.forEach((d, i) => {
          const barH = (d.value / tickInfo.max) * ph * this.animProgress;
          const x = i * gap + (gap - barWidth) / 2;
          const y = ph - barH;

          // 柱子渐变
          const grad = ctx.createLinearGradient(x, y, x, ph);
          grad.addColorStop(0, d.color);
          grad.addColorStop(1, d.color + '99');
          ctx.fillStyle = grad;

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

          this.bars.push({ x, y, w: barWidth, h: barH, data: d });

          // X轴标签
          ctx.fillStyle = '#888';
          ctx.font = '12px sans-serif';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'top';
          ctx.fillText(d.name, x + barWidth / 2, ph + 10);
        });

        // 悬停效果
        if (this.hoveredBar) {
          const bar = this.hoveredBar;
          // 高亮柱子
          ctx.fillStyle = 'rgba(255,255,255,0.3)';
          ctx.fillRect(bar.x, bar.y, bar.w, bar.h);

          // Tooltip
          const text = `${bar.data.name}: ${bar.data.value.toLocaleString()}万人`;
          ctx.font = 'bold 13px sans-serif';
          const tw = ctx.measureText(text).width;
          const tp = 10;
          const tx = bar.x + bar.w / 2;
          const ty = bar.y - 15;

          ctx.fillStyle = 'rgba(44,62,80,0.9)';
          ctx.beginPath();
          ctx.roundRect(tx - tw / 2 - tp, ty - 16, tw + tp * 2, 28, 6);
          ctx.fill();

          // 小三角
          ctx.beginPath();
          ctx.moveTo(tx - 6, ty + 12);
          ctx.lineTo(tx + 6, ty + 12);
          ctx.lineTo(tx, ty + 18);
          ctx.closePath();
          ctx.fill();

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

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

        // Y轴标题
        ctx.save();
        ctx.translate(-55, ph / 2);
        ctx.rotate(-Math.PI / 2);
        ctx.fillStyle = '#666';
        ctx.font = '13px sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText('人口(万人)', 0, 0);
        ctx.restore();

        ctx.restore();
      }
    }

    const chart = new BarChart('barChart', cityData);
  </script>
</body>
</html>

示例2:Canvas折线图(含平滑曲线与交互)

以下示例实现Canvas折线图,包含贝塞尔曲线平滑、数据点标记、十字准线和tooltip。

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas折线图</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-wrapper {
      background: #fff;
      border-radius: 16px;
      padding: 30px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      width: 100%;
      max-width: 900px;
    }
    .chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 20px; }
    canvas { width: 100%; }
  </style>
</head>
<body>
  <div class="chart-wrapper">
    <h2 class="chart-title">Canvas折线图 - 温度变化趋势</h2>
    <canvas id="lineChart"></canvas>
  </div>

  <script>
    const tempData = {
      labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
      series: [
        { name: '北京', color: '#e15759', data: [-3, 0, 8, 17, 23, 27, 29, 28, 22, 14, 5, -1] },
        { name: '上海', color: '#4e79a7', data: [4, 6, 10, 16, 21, 25, 29, 29, 25, 19, 13, 7] },
        { name: '广州', color: '#59a14f', data: [14, 15, 18, 23, 26, 28, 29, 29, 27, 24, 20, 15] }
      ]
    };

    function setupHiDPI(canvas, w, h) {
      const dpr = window.devicePixelRatio || 1;
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      canvas.style.width = w + 'px';
      canvas.style.height = h + 'px';
      const ctx = canvas.getContext('2d');
      ctx.scale(dpr, dpr);
      return ctx;
    }

    class LineChart {
      constructor(canvasId, data) {
        this.canvas = document.getElementById(canvasId);
        this.data = data;
        this.margin = { top: 40, right: 30, bottom: 50, left: 50 };
        this.displayWidth = this.canvas.parentElement.clientWidth - 60;
        this.displayHeight = Math.min(420, this.displayWidth * 0.5);
        this.mouseX = -1;
        this.mouseY = -1;
        this.animProgress = 0;

        this.ctx = setupHiDPI(this.canvas, this.displayWidth, this.displayHeight);
        this.setupEvents();
        this.animate();
      }

      get pw() { return this.displayWidth - this.margin.left - this.margin.right; }
      get ph() { return this.displayHeight - this.margin.top - this.margin.bottom; }

      setupEvents() {
        this.canvas.addEventListener('mousemove', (e) => {
          const rect = this.canvas.getBoundingClientRect();
          this.mouseX = (e.clientX - rect.left) * (this.displayWidth / rect.width);
          this.mouseY = (e.clientY - rect.top) * (this.displayHeight / rect.height);
          this.draw();
        });
        this.canvas.addEventListener('mouseleave', () => {
          this.mouseX = -1;
          this.mouseY = -1;
          this.draw();
        });
      }

      animate() {
        const start = performance.now();
        const duration = 1000;
        const step = (now) => {
          this.animProgress = Math.min(1, (now - start) / duration);
          this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
          this.draw();
          if (this.animProgress < 1) requestAnimationFrame(step);
        };
        requestAnimationFrame(step);
      }

      // 计算平滑曲线路径
      getSmoothedPath(points, tension = 0.3) {
        if (points.length < 2) return '';
        let path = `M ${points[0].x} ${points[0].y}`;
        for (let i = 0; i < points.length - 1; i++) {
          const p0 = points[Math.max(0, i - 1)];
          const p1 = points[i];
          const p2 = points[i + 1];
          const p3 = points[Math.min(points.length - 1, i + 2)];

          const cp1x = p1.x + (p2.x - p0.x) * tension;
          const cp1y = p1.y + (p2.y - p0.y) * tension;
          const cp2x = p2.x - (p3.x - p1.x) * tension;
          const cp2y = p2.y - (p3.y - p1.y) * tension;

          path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
        }
        return path;
      }

      draw() {
        const ctx = this.ctx;
        const m = this.margin;
        const pw = this.pw;
        const ph = this.ph;

        ctx.clearRect(0, 0, this.displayWidth, this.displayHeight);
        ctx.save();
        ctx.translate(m.left, m.top);

        // 数据范围
        const allValues = this.data.series.flatMap(s => s.data);
        const minVal = Math.min(...allValues);
        const maxVal = Math.max(...allValues);
        const padding = (maxVal - minVal) * 0.1;
        const yMin = Math.floor((minVal - padding) / 5) * 5;
        const yMax = Math.ceil((maxVal + padding) / 5) * 5;

        // 网格线
        const yTicks = 8;
        for (let i = 0; i <= yTicks; i++) {
          const val = yMin + (yMax - yMin) * i / yTicks;
          const y = ph - ((val - yMin) / (yMax - yMin)) * 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(Math.round(val) + 'C', -8, y);
        }

        // X轴标签
        const xStep = pw / (this.data.labels.length - 1);
        this.data.labels.forEach((label, i) => {
          ctx.fillStyle = '#888';
          ctx.font = '11px sans-serif';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'top';
          ctx.fillText(label, i * xStep, ph + 10);
        });

        // 绘制数据系列
        this.data.series.forEach((series) => {
          const points = series.data.map((v, i) => ({
            x: i * xStep,
            y: ph - ((v - yMin) / (yMax - yMin)) * ph
          }));

          // 动画裁剪
          const animWidth = pw * this.animProgress;
          ctx.save();
          ctx.beginPath();
          ctx.rect(0, -10, animWidth, ph + 20);
          ctx.clip();

          // 面积填充
          ctx.beginPath();
          const smoothPath = new Path2D(this.getSmoothedPath(points));
          ctx.addPath(smoothPath);
          ctx.lineTo(points[points.length - 1].x, ph);
          ctx.lineTo(points[0].x, ph);
          ctx.closePath();
          const areaGrad = ctx.createLinearGradient(0, 0, 0, ph);
          areaGrad.addColorStop(0, series.color + '30');
          areaGrad.addColorStop(1, series.color + '05');
          ctx.fillStyle = areaGrad;
          ctx.fill();

          // 折线
          ctx.strokeStyle = series.color;
          ctx.lineWidth = 2.5;
          ctx.lineJoin = 'round';
          ctx.lineCap = 'round';
          ctx.stroke(smoothPath);

          // 数据点
          points.forEach((p, i) => {
            if (p.x <= animWidth) {
              ctx.beginPath();
              ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
              ctx.fillStyle = '#fff';
              ctx.fill();
              ctx.strokeStyle = series.color;
              ctx.lineWidth = 2;
              ctx.stroke();
            }
          });

          ctx.restore();
        });

        // 十字准线和tooltip
        const plotLeft = m.left;
        const plotRight = m.left + pw;
        if (this.mouseX >= plotLeft && this.mouseX <= plotRight) {
          const relX = this.mouseX - m.left;
          const dataIndex = Math.round(relX / xStep);
          if (dataIndex >= 0 && dataIndex < this.data.labels.length) {
            const snapX = dataIndex * xStep;

            // 垂直准线
            ctx.strokeStyle = 'rgba(0,0,0,0.1)';
            ctx.lineWidth = 1;
            ctx.setLineDash([4, 4]);
            ctx.beginPath(); ctx.moveTo(snapX, 0); ctx.lineTo(snapX, ph); ctx.stroke();
            ctx.setLineDash([]);

            // 数据点高亮和tooltip
            let tooltipLines = [this.data.labels[dataIndex]];
            this.data.series.forEach((series) => {
              const v = series.data[dataIndex];
              const y = ph - ((v - yMin) / (yMax - yMin)) * ph;

              // 高亮点
              ctx.beginPath();
              ctx.arc(snapX, y, 6, 0, Math.PI * 2);
              ctx.fillStyle = series.color;
              ctx.fill();
              ctx.strokeStyle = '#fff';
              ctx.lineWidth = 2;
              ctx.stroke();

              tooltipLines.push(`${series.name}: ${v}C`);
            });

            // Tooltip
            ctx.font = '12px sans-serif';
            const lineHeight = 20;
            const maxTextWidth = Math.max(...tooltipLines.map(l => ctx.measureText(l).width));
            const tw = maxTextWidth + 20;
            const th = tooltipLines.length * lineHeight + 12;
            let tx = snapX + 15;
            if (tx + tw > pw) tx = snapX - tw - 15;
            const ty = 10;

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

            tooltipLines.forEach((line, i) => {
              ctx.fillStyle = i === 0 ? '#fff' : this.data.series[i - 1].color;
              ctx.font = i === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
              ctx.textAlign = 'left';
              ctx.textBaseline = 'top';
              ctx.fillText(line, tx + 10, ty + 8 + i * lineHeight);
            });
          }
        }

        // 图例
        const legendX = pw - 200;
        this.data.series.forEach((s, i) => {
          const lx = legendX + i * 70;
          ctx.strokeStyle = s.color;
          ctx.lineWidth = 3;
          ctx.beginPath(); ctx.moveTo(lx, -25); ctx.lineTo(lx + 20, -25); ctx.stroke();
          ctx.beginPath(); ctx.arc(lx + 10, -25, 3, 0, Math.PI * 2);
          ctx.fillStyle = '#fff'; ctx.fill();
          ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.stroke();
          ctx.fillStyle = '#666'; ctx.font = '12px sans-serif';
          ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
          ctx.fillText(s.name, lx + 25, -25);
        });

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

        ctx.restore();
      }
    }

    const lineChart = new LineChart('lineChart', tempData);
  </script>
</body>
</html>

示例3:Canvas与SVG性能对比

以下示例直观展示Canvas和SVG在不同数据量下的渲染性能差异。

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas与SVG性能对比</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; }
    .controls {
      display: flex;
      gap: 10px;
      justify-content: center;
      margin-bottom: 20px;
      flex-wrap: wrap;
    }
    .controls button {
      padding: 10px 24px;
      border: none;
      border-radius: 8px;
      background: #4e79a7;
      color: #fff;
      font-size: 14px;
      cursor: pointer;
      transition: background 0.2s;
    }
    .controls button:hover { background: #3d6a96; }
    .controls button.active { background: #e15759; }
    .comparison {
      display: flex;
      gap: 20px;
      max-width: 1200px;
      margin: 0 auto;
      flex-wrap: wrap;
      justify-content: center;
    }
    .panel {
      background: #fff;
      border-radius: 12px;
      padding: 20px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.08);
      flex: 1;
      min-width: 400px;
    }
    .panel h2 { text-align: center; color: #34495e; margin-bottom: 10px; font-size: 16px; }
    .render-time {
      text-align: center;
      font-size: 14px;
      margin-bottom: 10px;
      font-weight: 600;
    }
    .time-svg { color: #4e79a7; }
    .time-canvas { color: #e15759; }
    canvas, svg { width: 100%; border: 1px solid #eee; border-radius: 8px; background: #fafafa; }
    .result-table {
      max-width: 1200px;
      margin: 20px auto 0;
      background: #fff;
      border-radius: 12px;
      padding: 20px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.08);
    }
    .result-table h2 { text-align: center; color: #34495e; margin-bottom: 10px; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 8px 12px; text-align: center; border-bottom: 1px solid #eee; }
    th { background: #f8f9fa; color: #2c3e50; font-weight: 600; }
    .faster { color: #27ae60; font-weight: 600; }
    .slower { color: #e74c3c; }
  </style>
</head>
<body>
  <h1>Canvas 与 SVG 渲染性能对比</h1>
  <div class="controls">
    <button onclick="runTest(100)">100 个元素</button>
    <button onclick="runTest(500)">500 个元素</button>
    <button onclick="runTest(1000)">1,000 个元素</button>
    <button onclick="runTest(5000)">5,000 个元素</button>
    <button onclick="runTest(10000)">10,000 个元素</button>
  </div>

  <div class="comparison">
    <div class="panel">
      <h2>SVG 渲染</h2>
      <div class="render-time time-svg" id="svgTime">等待测试...</div>
      <svg id="svgTest" viewBox="0 0 800 400" height="400"></svg>
    </div>
    <div class="panel">
      <h2>Canvas 渲染</h2>
      <div class="render-time time-canvas" id="canvasTime">等待测试...</div>
      <canvas id="canvasTest" width="800" height="400"></canvas>
    </div>
  </div>

  <div class="result-table">
    <h2>性能测试结果</h2>
    <table>
      <thead>
        <tr><th>元素数量</th><th>SVG 耗时</th><th>Canvas 耗时</th><th>差异</th></tr>
      </thead>
      <tbody id="resultBody"></tbody>
    </table>
  </div>

  <script>
    const results = [];

    function generateData(count) {
      const data = [];
      for (let i = 0; i < count; i++) {
        data.push({
          x: Math.random() * 760 + 20,
          y: Math.random() * 360 + 20,
          r: Math.random() * 6 + 2,
          color: `hsl(${Math.random() * 360}, 60%, 55%)`
        });
      }
      return data;
    }

    function testSVG(data) {
      const svg = document.getElementById('svgTest');
      const start = performance.now();

      // 清除
      while (svg.firstChild) svg.removeChild(svg.firstChild);

      // 绘制
      data.forEach(d => {
        const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        circle.setAttribute('cx', d.x);
        circle.setAttribute('cy', d.y);
        circle.setAttribute('r', d.r);
        circle.setAttribute('fill', d.color);
        circle.setAttribute('opacity', '0.7');
        svg.appendChild(circle);
      });

      const end = performance.now();
      return end - start;
    }

    function testCanvas(data) {
      const canvas = document.getElementById('canvasTest');
      const ctx = canvas.getContext('2d');
      const start = performance.now();

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      data.forEach(d => {
        ctx.beginPath();
        ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
        ctx.fillStyle = d.color;
        ctx.globalAlpha = 0.7;
        ctx.fill();
      });
      ctx.globalAlpha = 1;

      const end = performance.now();
      return end - start;
    }

    function runTest(count) {
      const data = generateData(count);

      // SVG测试
      const svgTime = testSVG(data);
      document.getElementById('svgTime').textContent = `渲染耗时: ${svgTime.toFixed(2)} ms`;

      // Canvas测试
      const canvasTime = testCanvas(data);
      document.getElementById('canvasTime').textContent = `渲染耗时: ${canvasTime.toFixed(2)} ms`;

      // 记录结果
      results.push({ count, svgTime, canvasTime });
      updateResultTable();
    }

    function updateResultTable() {
      const tbody = document.getElementById('resultBody');
      tbody.innerHTML = '';
      results.forEach(r => {
        const faster = r.svgTime < r.canvasTime ? 'SVG' : 'Canvas';
        const ratio = Math.abs(r.svgTime - r.canvasTime) / Math.min(r.svgTime, r.canvasTime);
        const row = document.createElement('tr');
        row.innerHTML = `
          <td>${r.count.toLocaleString()}</td>
          <td class="${faster === 'SVG' ? 'faster' : 'slower'}">${r.svgTime.toFixed(2)} ms</td>
          <td class="${faster === 'Canvas' ? 'faster' : 'slower'}">${r.canvasTime.toFixed(2)} ms</td>
          <td>${faster} 快 ${ratio.toFixed(1)}x</td>
        `;
        tbody.appendChild(row);
      });
    }
  </script>
</body>
</html>

浏览器兼容性

Canvas特性 Chrome Firefox Safari Edge IE11
Canvas 2D基础 4+ 3.6+ 4+ 12+ 9+
roundRect 99+ 112+ 16+ 99+ 不支持
Path2D 36+ 31+ 8+ 14+ 不支持
measureText 4+ 3.6+ 4+ 12+ 9+
createLinearGradient 4+ 3.6+ 4+ 12+ 9+
globalCompositeOperation 4+ 3.6+ 4+ 12+ 9+
OffscreenCanvas 69+ 105+ 16.4+ 79+ 不支持
devicePixelRatio 4+ 4+ 5+ 12+ 不支持

注意事项与最佳实践

  1. 高DPI适配:始终处理devicePixelRatio,确保Canvas在Retina屏幕上清晰显示。

  2. 状态管理:使用save/restore管理Canvas状态,避免状态污染。

  3. 避免频繁重绘:对于静态内容,只在数据变化时重绘;对于动画,使用requestAnimationFrame。

  4. 内存管理:长时间运行的Canvas应用注意释放不再使用的ImageData和Pattern对象。

  5. 文本测量:使用measureText()精确计算文本宽度,避免硬编码。

  6. 事件坐标转换:鼠标事件坐标需要转换为Canvas内部坐标,考虑缩放和偏移。

  7. 性能优化:大量相同颜色的图形可以合并为一次fill操作,减少状态切换。

  8. roundRect兼容性:roundRect是较新的API,旧浏览器需要用quadraticCurveTo手动实现圆角矩形。

代码规范示例

代码示例

// 好的做法:封装Canvas工具类
class CanvasHelper {
  constructor(canvas, width, height) {
    this.canvas = canvas;
    this.dpr = window.devicePixelRatio || 1;
    this.logicalWidth = width;
    this.logicalHeight = height;
    this._setupHiDPI();
  }

  _setupHiDPI() {
    this.canvas.width = this.logicalWidth * this.dpr;
    this.canvas.height = this.logicalHeight * this.dpr;
    this.canvas.style.width = this.logicalWidth + 'px';
    this.canvas.style.height = this.logicalHeight + 'px';
    this.ctx = this.canvas.getContext('2d');
    this.ctx.scale(this.dpr, this.dpr);
  }

  clear() {
    this.ctx.clearRect(0, 0, this.logicalWidth, this.logicalHeight);
  }

  roundRect(x, y, w, h, r) {
    const ctx = this.ctx;
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y);
    ctx.quadraticCurveTo(x + w, y, x + w, y + r);
    ctx.lineTo(x + w, y + h - r);
    ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    ctx.lineTo(x + r, y + h);
    ctx.quadraticCurveTo(x, y + h, x, y + h - r);
    ctx.lineTo(x, y + r);
    ctx.quadraticCurveTo(x, y, x + r, y);
    ctx.closePath();
  }

  getMousePos(e) {
    const rect = this.canvas.getBoundingClientRect();
    return {
      x: (e.clientX - rect.left) * (this.logicalWidth / rect.width),
      y: (e.clientY - rect.top) * (this.logicalHeight / rect.height)
    };
  }
}

// 不好的做法:每次绘制都重新获取context
// function draw() {
//   const ctx = document.getElementById('canvas').getContext('2d');
//   ctx.fillRect(0, 0, 100, 100);
// }

// 好的做法:缓存context引用
// const ctx = canvas.getContext('2d'); // 只获取一次

常见问题与解决方案

问题1:Canvas在高DPI屏幕模糊

解决方案:使用devicePixelRatio进行适配

代码示例

function setupHiDPICanvas(canvas, w, h) {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = w * dpr;
  canvas.height = h * dpr;
  canvas.style.width = w + 'px';
  canvas.style.height = h + 'px';
  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  return ctx;
}

问题2:Canvas尺寸为0

原因:Canvas在CSS中设置了display:none或父元素不可见时,getBoundingClientRect返回0。

解决方案

  • 确保Canvas在可见状态下初始化

  • 使用ResizeObserver监听尺寸变化

  • 设置明确的width/height属性

问题3:Canvas内存泄漏

原因:频繁创建ImageData、Pattern等对象未释放。

解决方案

  • 复用ImageData对象

  • 及时清除不再使用的引用

  • 对于动画Canvas,使用requestAnimationFrame而非setInterval

问题4:Canvas文本渲染模糊

原因:与高DPI问题类似,或字体大小过小。

解决方案

  • 确保高DPI适配

  • 使用12px以上的字体大小

  • 设置ctx.textRendering = 'optimizeLegibility'

总结

本教程系统讲解了Canvas图表的基础知识:

  1. Canvas绘图API:fillRect、beginPath、arc、bezierCurveTo等核心方法和样式属性

  2. 坐标映射:数据空间到像素空间的线性映射和反向映射

  3. 刻度计算:基于1-2-5规则的美观刻度算法

  4. 图例绘制:手动绘制色块和标签

  5. 交互提示:命中检测和tooltip实现

  6. Canvas vs SVG:大数据量选Canvas,复杂交互选SVG

  7. 性能对比:Canvas在大数据量场景下性能优势明显

掌握Canvas基础后,你将在后续教程中学习如何使用Canvas实现各类具体图表类型。

常见问题

Canvas在高DPI屏幕模糊

- 确保Canvas在可见状态下初始化 - 使用ResizeObserver监听尺寸变化 - 设置明确的width/height属性

Canvas尺寸为0

- 复用ImageData对象 - 及时清除不再使用的引用 - 对于动画Canvas,使用requestAnimationFrame而非setInterval

Canvas内存泄漏

- 确保高DPI适配 - 使用12px以上的字体大小 - 设置ctx.textRendering = 'optimizeLegibility'

标签: 绑定渲染 柱状图 内存管理 场景管理 帧率优化 请求缓存 数据可视化 Chart

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

本文涉及AI创作

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

list快速访问

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

poll相关推荐