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

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

教程简介

折线图是展示数据随时间或有序维度变化趋势的最佳图表类型。通过连接数据点的线段,折线图能够直观地呈现数据的上升、下降、波动和转折,是时间序列分析中最常用的可视化手段。本教程将全面讲解折线图的各类实现,包括基础折线图、多系列折线图、面积填充、平滑曲线、数据点标记、缩放与平移以及实时数据更新,帮助你掌握折线图的完整实现能力。

核心概念

折线图的类型

  1. 基础折线图:单条折线展示一个数据系列的趋势

  2. 多系列折线图:多条折线展示多个数据系列的对比趋势

  3. 面积折线图:折线下方填充颜色,强调趋势的量级

  4. 堆叠面积图:多个面积图堆叠,展示总量和各部分贡献

  5. 平滑折线图:使用贝塞尔曲线连接数据点,视觉更柔和

  6. 阶梯折线图:数据点之间使用水平/垂直线段,适合离散数据

折线图的设计要素

  • 线条样式:线宽、颜色、虚线样式

  • 数据点标记:圆形、方形、三角形等,大小适中

  • 面积填充:半透明渐变填充,不遮挡其他系列

  • 坐标轴:X轴通常为时间轴,Y轴为数值轴

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

  • 图例:多系列时必须提供图例

  • 交互:十字准线、tooltip、数据点高亮

折线图的适用场景

  • 展示数据随时间的变化趋势

  • 比较多个指标的变化趋势

  • 识别数据的周期性和季节性

  • 发现数据的异常点和转折点

  • 实时监控数据的动态变化

平滑曲线算法

折线图常用的平滑算法:

1. 三次贝塞尔曲线(Cardinal Spline)

代码示例

function cardinalSpline(points, tension = 0.3) {
  const path = [];
  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.push({ cp1: {x: cp1x, y: cp1y}, cp2: {x: cp2x, y: cp2y}, end: p2 });
  }
  return path;
}

2. 单调三次插值(Monotone Cubic)

保证曲线单调递增/递减,不会出现折线图中常见的"过冲"现象。

语法与用法

折线图绘制核心逻辑

代码示例

function drawLineChart(ctx, data, config) {
  const { margin, plotWidth, plotHeight } = config;
  const maxVal = Math.max(...data.values);
  const xStep = plotWidth / (data.values.length - 1);

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

  // 绘制网格
  drawGrid(ctx, maxVal, plotWidth, plotHeight);

  // 计算数据点坐标
  const points = data.values.map((v, i) => ({
    x: i * xStep,
    y: plotHeight - (v / maxVal) * plotHeight
  }));

  // 绘制面积填充
  ctx.beginPath();
  ctx.moveTo(points[0].x, plotHeight);
  points.forEach(p => ctx.lineTo(p.x, p.y));
  ctx.lineTo(points[points.length - 1].x, plotHeight);
  ctx.closePath();
  ctx.fillStyle = data.color + '20';
  ctx.fill();

  // 绘制折线
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
  ctx.strokeStyle = data.color;
  ctx.lineWidth = 2;
  ctx.stroke();

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

  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;
      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: 950px;
    }
    .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: 20px; }
    .options { display: flex; gap: 12px; align-items: center; }
    .toggle-group { display: flex; gap: 4px; }
    .toggle-btn {
      padding: 5px 14px;
      border: 1px solid #ddd;
      border-radius: 6px;
      background: #fff;
      color: #666;
      font-size: 12px;
      cursor: pointer;
      transition: all 0.2s;
    }
    .toggle-btn.active { background: #4e79a7; color: #fff; border-color: #4e79a7; }
    .legend { display: flex; gap: 18px; margin-top: 15px; justify-content: center; flex-wrap: wrap; }
    .legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; cursor: pointer; }
    .legend-item.disabled { opacity: 0.3; }
    .legend-line { width: 20px; height: 3px; border-radius: 2px; }
    canvas { width: 100%; cursor: crosshair; }
  </style>
</head>
<body>
  <div class="chart-container">
    <div class="chart-header">
      <span class="chart-title">股票价格走势</span>
      <div class="options">
        <div class="toggle-group">
          <button class="toggle-btn active" onclick="setSmooth(false)">折线</button>
          <button class="toggle-btn" onclick="setSmooth(true)">平滑</button>
        </div>
        <div class="toggle-group">
          <button class="toggle-btn" onclick="setArea(false)">无填充</button>
          <button class="toggle-btn active" onclick="setArea(true)">面积填充</button>
        </div>
      </div>
    </div>
    <canvas id="lineChart"></canvas>
    <div class="legend" id="legend"></div>
  </div>

  <script>
    const stockData = {
      labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
      series: [
        { name: '科技A', color: '#4e79a7', data: [45,52,48,65,72,68,85,92,88,95,102,110], visible: true },
        { name: '金融B', color: '#e15759', data: [30,28,35,42,38,45,50,48,55,60,58,65], visible: true },
        { name: '消费C', color: '#59a14f', data: [55,58,52,60,65,62,70,75,72,78,82,88], visible: true }
      ]
    };

    let useSmooth = false;
    let useArea = true;
    let mouseX = -1;
    let animProgress = 0;

    const canvas = document.getElementById('lineChart');
    const dpr = window.devicePixelRatio || 1;
    let displayW = canvas.parentElement.clientWidth - 60;
    let displayH = Math.min(420, displayW * 0.48);
    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: 25, right: 25, bottom: 45, left: 55 };

    function renderLegend() {
      document.getElementById('legend').innerHTML = stockData.series.map((s, i) =>
        `<div class="legend-item ${s.visible ? '' : 'disabled'}" onclick="toggleSeries(${i})">
          <div class="legend-line" style="background:${s.color}"></div>${s.name}
        </div>`
      ).join('');
    }

    function toggleSeries(index) {
      stockData.series[index].visible = !stockData.series[index].visible;
      renderLegend();
      drawChart();
    }

    function setSmooth(val) {
      useSmooth = val;
      document.querySelectorAll('.toggle-group')[0].querySelectorAll('.toggle-btn').forEach((btn, i) => {
        btn.classList.toggle('active', (i === 0 && !val) || (i === 1 && val));
      });
      drawChart();
    }

    function setArea(val) {
      useArea = val;
      document.querySelectorAll('.toggle-group')[1].querySelectorAll('.toggle-btn').forEach((btn, i) => {
        btn.classList.toggle('active', (i === 0 && !val) || (i === 1 && val));
      });
      drawChart();
    }

    function getControlPoints(points, tension) {
      const cps = [];
      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)];
        cps.push({
          cp1x: p1.x + (p2.x - p0.x) * tension,
          cp1y: p1.y + (p2.y - p0.y) * tension,
          cp2x: p2.x - (p3.x - p1.x) * tension,
          cp2y: p2.y - (p3.y - p1.y) * tension
        });
      }
      return cps;
    }

    function drawSmoothLine(ctx, points, tension = 0.3) {
      const cps = getControlPoints(points, tension);
      ctx.moveTo(points[0].x, points[0].y);
      cps.forEach((cp, i) => {
        ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, points[i + 1].x, points[i + 1].y);
      });
    }

    function drawChart() {
      const pw = displayW - margin.left - margin.right;
      const ph = displayH - margin.top - margin.bottom;
      ctx.clearRect(0, 0, displayW, displayH);
      ctx.save();
      ctx.translate(margin.left, margin.top);

      const visibleSeries = stockData.series.filter(s => s.visible);
      const allValues = visibleSeries.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 xStep = pw / (stockData.labels.length - 1);

      // 网格
      for (let i = 0; i <= 6; i++) {
        const v = yMin + (yMax - yMin) * i / 6;
        const y = ph - ((v - 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(v), -8, y);
      }

      // X轴标签
      stockData.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);
      });

      // 动画裁剪
      ctx.save();
      ctx.beginPath();
      ctx.rect(0, -5, pw * animProgress + 5, ph + 10);
      ctx.clip();

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

        // 面积填充
        if (useArea) {
          ctx.beginPath();
          if (useSmooth) {
            drawSmoothLine(ctx, points);
          } else {
            ctx.moveTo(points[0].x, points[0].y);
            points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
          }
          ctx.lineTo(points[points.length - 1].x, ph);
          ctx.lineTo(points[0].x, ph);
          ctx.closePath();
          const grad = ctx.createLinearGradient(0, 0, 0, ph);
          grad.addColorStop(0, series.color + '30');
          grad.addColorStop(1, series.color + '05');
          ctx.fillStyle = grad;
          ctx.fill();
        }

        // 折线
        ctx.beginPath();
        if (useSmooth) {
          drawSmoothLine(ctx, points);
        } else {
          ctx.moveTo(points[0].x, points[0].y);
          points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
        }
        ctx.strokeStyle = series.color;
        ctx.lineWidth = 2.5;
        ctx.lineJoin = 'round';
        ctx.lineCap = 'round';
        ctx.stroke();

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

      ctx.restore(); // 恢复裁剪

      // 十字准线和tooltip
      const plotLeft = margin.left;
      const plotRight = margin.left + pw;
      if (mouseX >= plotLeft && mouseX <= plotRight) {
        const relX = mouseX - margin.left;
        const idx = Math.round(relX / xStep);
        if (idx >= 0 && idx < stockData.labels.length) {
          const snapX = idx * 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内容
          const tooltipItems = [];
          visibleSeries.forEach(series => {
            const v = series.data[idx];
            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();

            tooltipItems.push({ name: series.name, value: v, color: series.color });
          });

          // Tooltip
          const lines = [stockData.labels[idx], ...tooltipItems.map(t => `${t.name}: ${t.value}`)];
          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;
          let tx = snapX + 15;
          if (tx + tw > pw) tx = snapX - tw - 15;

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

          lines.forEach((line, li) => {
            ctx.fillStyle = li === 0 ? '#fff' : tooltipItems[li - 1].color;
            ctx.font = li === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
            ctx.textAlign = 'left';
            ctx.fillText(line, tx + 10, 19 + 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();
    }

    // 交互
    canvas.addEventListener('mousemove', (e) => {
      const rect = canvas.getBoundingClientRect();
      mouseX = (e.clientX - rect.left) * (displayW / rect.width);
      drawChart();
    });

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

    // 入场动画
    function animate() {
      const start = performance.now();
      function step(now) {
        animProgress = Math.min(1, (now - start) / 1200);
        animProgress = 1 - Math.pow(1 - animProgress, 3);
        drawChart();
        if (animProgress < 1) requestAnimationFrame(step);
      }
      requestAnimationFrame(step);
    }

    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: #1a1a2e;
      padding: 20px;
      color: #eee;
    }
    h1 { text-align: center; margin-bottom: 20px; font-weight: 300; }
    .monitor {
      max-width: 950px;
      margin: 0 auto;
    }
    .metrics-row {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
      gap: 15px;
      margin-bottom: 20px;
    }
    .metric-card {
      background: #16213e;
      border-radius: 12px;
      padding: 18px;
      border: 1px solid #0f3460;
    }
    .metric-label { font-size: 12px; color: #888; margin-bottom: 6px; }
    .metric-value { font-size: 28px; font-weight: 700; }
    .metric-unit { font-size: 14px; color: #888; margin-left: 4px; }
    .chart-card {
      background: #16213e;
      border-radius: 16px;
      padding: 25px;
      border: 1px solid #0f3460;
      margin-bottom: 15px;
    }
    .chart-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 15px;
    }
    .chart-title { font-size: 16px; font-weight: 600; }
    .live-badge {
      display: flex;
      align-items: center;
      gap: 6px;
      font-size: 12px;
      color: #4ecca3;
    }
    .live-dot {
      width: 8px; height: 8px;
      border-radius: 50%;
      background: #4ecca3;
      animation: pulse 1.5s infinite;
    }
    @keyframes pulse {
      0%, 100% { opacity: 1; }
      50% { opacity: 0.3; }
    }
    canvas { width: 100%; }
    .controls {
      display: flex;
      gap: 10px;
      justify-content: center;
    }
    .ctrl-btn {
      padding: 8px 20px;
      border: 1px solid #0f3460;
      border-radius: 8px;
      background: #16213e;
      color: #eee;
      font-size: 13px;
      cursor: pointer;
      transition: all 0.2s;
    }
    .ctrl-btn:hover { background: #0f3460; }
    .ctrl-btn.active { background: #4e79a7; border-color: #4e79a7; }
  </style>
</head>
<body>
  <h1>服务器实时监控</h1>
  <div class="monitor">
    <div class="metrics-row" id="metrics"></div>
    <div class="chart-card">
      <div class="chart-header">
        <span class="chart-title">CPU 与内存使用率</span>
        <div class="live-badge"><div class="live-dot"></div>实时</div>
      </div>
      <canvas id="realtimeChart"></canvas>
    </div>
    <div class="controls">
      <button class="ctrl-btn active" onclick="setSpeed(1000)">1秒</button>
      <button class="ctrl-btn" onclick="setSpeed(500)">0.5秒</button>
      <button class="ctrl-btn" onclick="setSpeed(2000)">2秒</button>
      <button class="ctrl-btn" onclick="togglePause()">暂停/继续</button>
    </div>
  </div>

  <script>
    const MAX_POINTS = 60;
    const cpuData = [];
    const memData = [];
    let paused = false;
    let updateInterval = 1000;
    let timer = null;

    // 初始化数据
    for (let i = 0; i < MAX_POINTS; i++) {
      cpuData.push(30 + Math.random() * 20);
      memData.push(50 + Math.random() * 15);
    }

    const canvas = document.getElementById('realtimeChart');
    const dpr = window.devicePixelRatio || 1;
    const displayW = canvas.parentElement.clientWidth - 50;
    const displayH = 300;
    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: 20, bottom: 30, left: 45 };
    const pw = displayW - margin.left - margin.right;
    const ph = displayH - margin.top - margin.bottom;

    function generateValue(prev, min, max, volatility) {
      const change = (Math.random() - 0.5) * volatility;
      return Math.max(min, Math.min(max, prev + change));
    }

    function updateMetrics() {
      const cpu = cpuData[cpuData.length - 1];
      const mem = memData[memData.length - 1];
      document.getElementById('metrics').innerHTML = `
        <div class="metric-card">
          <div class="metric-label">CPU 使用率</div>
          <div class="metric-value" style="color:#4ecca3">${cpu.toFixed(1)}<span class="metric-unit">%</span></div>
        </div>
        <div class="metric-card">
          <div class="metric-label">内存使用率</div>
          <div class="metric-value" style="color:#e15759">${mem.toFixed(1)}<span class="metric-unit">%</span></div>
        </div>
        <div class="metric-card">
          <div class="metric-label">网络流量</div>
          <div class="metric-value" style="color:#f28e2b">${(Math.random() * 100 + 50).toFixed(0)}<span class="metric-unit">MB/s</span></div>
        </div>
        <div class="metric-card">
          <div class="metric-label">磁盘 I/O</div>
          <div class="metric-value" style="color:#59a14f">${(Math.random() * 50 + 10).toFixed(0)}<span class="metric-unit">MB/s</span></div>
        </div>
      `;
    }

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

      const yMin = 0;
      const yMax = 100;

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

      // 时间标签
      const now = new Date();
      for (let i = 0; i < 6; i++) {
        const x = pw * i / 5;
        const sec = Math.round((MAX_POINTS - 1) * (1 - i / 5));
        const t = new Date(now.getTime() - sec * updateInterval);
        ctx.fillStyle = '#555'; ctx.font = '10px sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText(t.toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'}), x, ph + 18);
      }

      // 绘制函数
      function drawSeries(data, color, fillOpacity) {
        const xStep = pw / (MAX_POINTS - 1);
        const points = data.map((v, i) => ({
          x: i * xStep,
          y: ph - (v / yMax) * ph
        }));

        // 面积
        ctx.beginPath();
        ctx.moveTo(points[0].x, ph);
        points.forEach(p => ctx.lineTo(p.x, p.y));
        ctx.lineTo(points[points.length - 1].x, ph);
        ctx.closePath();
        const grad = ctx.createLinearGradient(0, 0, 0, ph);
        grad.addColorStop(0, color + fillOpacity);
        grad.addColorStop(1, color + '05');
        ctx.fillStyle = grad;
        ctx.fill();

        // 线条
        ctx.beginPath();
        points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.lineJoin = 'round';
        ctx.stroke();

        // 最新点高亮
        const last = points[points.length - 1];
        ctx.beginPath();
        ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
        ctx.fillStyle = color;
        ctx.fill();
        ctx.beginPath();
        ctx.arc(last.x, last.y, 8, 0, Math.PI * 2);
        ctx.strokeStyle = color + '40';
        ctx.lineWidth = 2;
        ctx.stroke();
      }

      drawSeries(memData, '#e15759', '25');
      drawSeries(cpuData, '#4ecca3', '25');

      // 图例
      const legendY = -8;
      [{name:'CPU',color:'#4ecca3',x:pw-120},{name:'内存',color:'#e15759',x:pw-50}].forEach(item => {
        ctx.strokeStyle = item.color; ctx.lineWidth = 3;
        ctx.beginPath(); ctx.moveTo(item.x, legendY); ctx.lineTo(item.x + 18, legendY); ctx.stroke();
        ctx.fillStyle = '#aaa'; ctx.font = '11px sans-serif';
        ctx.textAlign = 'left';
        ctx.fillText(item.name, item.x + 22, legendY + 4);
      });

      ctx.restore();
    }

    function addDataPoint() {
      if (paused) return;
      const lastCpu = cpuData[cpuData.length - 1];
      const lastMem = memData[memData.length - 1];
      cpuData.push(generateValue(lastCpu, 10, 95, 8));
      memData.push(generateValue(lastMem, 30, 90, 4));
      if (cpuData.length > MAX_POINTS) cpuData.shift();
      if (memData.length > MAX_POINTS) memData.shift();
      updateMetrics();
      drawChart();
    }

    function setSpeed(ms) {
      updateInterval = ms;
      document.querySelectorAll('.ctrl-btn').forEach(btn => btn.classList.remove('active'));
      event.target.classList.add('active');
      clearInterval(timer);
      timer = setInterval(addDataPoint, updateInterval);
    }

    function togglePause() {
      paused = !paused;
    }

    // 启动
    updateMetrics();
    drawChart();
    timer = setInterval(addDataPoint, updateInterval);
  </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: 950px;
    }
    .chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 5px; }
    .chart-hint { text-align: center; color: #999; font-size: 13px; margin-bottom: 15px; }
    canvas { width: 100%; cursor: grab; }
    canvas:active { cursor: grabbing; }
    .zoom-info {
      text-align: center;
      margin-top: 10px;
      font-size: 13px;
      color: #888;
    }
    .reset-btn {
      display: inline-block;
      padding: 4px 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
      background: #fff;
      color: #666;
      font-size: 12px;
      cursor: pointer;
      margin-left: 8px;
    }
    .reset-btn:hover { background: #f0f0f0; }
  </style>
</head>
<body>
  <div class="chart-container">
    <h2 class="chart-title">一年温度数据(支持缩放与平移)</h2>
    <p class="chart-hint">鼠标滚轮缩放 / 拖拽平移 / 双击重置</p>
    <canvas id="zoomChart"></canvas>
    <div class="zoom-info">
      当前视图: <span id="viewRange">全部数据</span>
      <button class="reset-btn" onclick="resetView()">重置视图</button>
    </div>
  </div>

  <script>
    // 生成365天的温度数据
    const tempData = [];
    const startDate = new Date(2024, 0, 1);
    for (let i = 0; i < 365; i++) {
      const date = new Date(startDate.getTime() + i * 86400000);
      const dayOfYear = i;
      // 模拟正弦温度曲线 + 随机波动
      const base = 15 + 15 * Math.sin((dayOfYear - 80) / 365 * 2 * Math.PI);
      const noise = (Math.random() - 0.5) * 8;
      tempData.push({
        date,
        value: Math.round((base + noise) * 10) / 10,
        label: `${date.getMonth() + 1}/${date.getDate()}`
      });
    }

    const canvas = document.getElementById('zoomChart');
    const dpr = window.devicePixelRatio || 1;
    const displayW = canvas.parentElement.clientWidth - 60;
    const displayH = 350;
    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: 20, bottom: 40, left: 50 };
    const pw = displayW - margin.left - margin.right;
    const ph = displayH - margin.top - margin.bottom;

    // 视图状态
    let viewStart = 0;
    let viewEnd = tempData.length - 1;
    let isDragging = false;
    let dragStartX = 0;
    let dragStartViewStart = 0;
    let dragStartViewEnd = 0;

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

      const visibleData = tempData.slice(viewStart, viewEnd + 1);
      if (visibleData.length === 0) { ctx.restore(); return; }

      const values = visibleData.map(d => d.value);
      const minVal = Math.min(...values);
      const maxVal = Math.max(...values);
      const pad = (maxVal - minVal) * 0.1 || 5;
      const yMin = Math.floor((minVal - pad) / 5) * 5;
      const yMax = Math.ceil((maxVal + pad) / 5) * 5;

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

      // X轴标签
      const labelCount = Math.min(12, visibleData.length);
      const labelStep = Math.floor(visibleData.length / labelCount);
      for (let i = 0; i < visibleData.length; i += labelStep) {
        const x = (i / (visibleData.length - 1)) * pw;
        ctx.fillStyle = '#888'; ctx.font = '10px sans-serif';
        ctx.textAlign = 'center'; ctx.textBaseline = 'top';
        ctx.fillText(visibleData[i].label, x, ph + 8);
      }

      // 数据点坐标
      const points = visibleData.map((d, i) => ({
        x: (i / (visibleData.length - 1)) * pw,
        y: ph - ((d.value - yMin) / (yMax - yMin)) * ph
      }));

      // 面积
      ctx.beginPath();
      ctx.moveTo(points[0].x, ph);
      points.forEach(p => ctx.lineTo(p.x, p.y));
      ctx.lineTo(points[points.length - 1].x, ph);
      ctx.closePath();
      const grad = ctx.createLinearGradient(0, 0, 0, ph);
      grad.addColorStop(0, '#4e79a730');
      grad.addColorStop(1, '#4e79a705');
      ctx.fillStyle = grad;
      ctx.fill();

      // 折线
      ctx.beginPath();
      points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
      ctx.strokeStyle = '#4e79a7';
      ctx.lineWidth = visibleData.length > 100 ? 1.5 : 2;
      ctx.lineJoin = 'round';
      ctx.stroke();

      // 数据点(数量少时显示)
      if (visibleData.length <= 50) {
        points.forEach(p => {
          ctx.beginPath();
          ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
          ctx.fillStyle = '#fff';
          ctx.fill();
          ctx.strokeStyle = '#4e79a7';
          ctx.lineWidth = 1.5;
          ctx.stroke();
        });
      }

      // 坐标轴
      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 startD = tempData[viewStart].label;
      const endD = tempData[viewEnd].label;
      document.getElementById('viewRange').textContent = `${startD} - ${endD} (${visibleData.length}天)`;
    }

    // 缩放
    canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      const rect = canvas.getBoundingClientRect();
      const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
      const ratio = mx / pw;

      const range = viewEnd - viewStart;
      const zoomFactor = e.deltaY > 0 ? 1.2 : 0.8;
      const newRange = Math.max(10, Math.min(tempData.length - 1, Math.round(range * zoomFactor)));

      const center = viewStart + range * ratio;
      viewStart = Math.max(0, Math.round(center - newRange * ratio));
      viewEnd = Math.min(tempData.length - 1, viewStart + newRange);
      viewStart = Math.max(0, viewEnd - newRange);

      drawChart();
    });

    // 平移
    canvas.addEventListener('mousedown', (e) => {
      isDragging = true;
      dragStartX = e.clientX;
      dragStartViewStart = viewStart;
      dragStartViewEnd = viewEnd;
    });

    canvas.addEventListener('mousemove', (e) => {
      if (!isDragging) return;
      const rect = canvas.getBoundingClientRect();
      const dx = e.clientX - dragStartX;
      const range = dragStartViewEnd - dragStartViewStart;
      const shift = -Math.round(dx / pw * range);

      viewStart = Math.max(0, Math.min(tempData.length - 1 - range, dragStartViewStart + shift));
      viewEnd = viewStart + range;
      drawChart();
    });

    canvas.addEventListener('mouseup', () => isDragging = false);
    canvas.addEventListener('mouseleave', () => isDragging = false);

    // 双击重置
    canvas.addEventListener('dblclick', resetView);

    function resetView() {
      viewStart = 0;
      viewEnd = tempData.length - 1;
      drawChart();
    }

    drawChart();
  </script>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge IE11
Canvas 2D 4+ 3.6+ 4+ 12+ 9+
bezierCurveTo 4+ 3.6+ 4+ 12+ 9+
setLineDash 4+ 27+ 6+ 12+ 11+
roundRect 99+ 112+ 16+ 99+ 不支持
WheelEvent 31+ 17+ 7+ 12+ 9+
setInterval精度 4+ 4+ 4+ 12+ 9+

注意事项与最佳实践

  1. 数据点密度:数据点过多时隐藏数据点标记,仅显示线条;数据点少时显示标记。

  2. 平滑曲线谨慎使用:平滑曲线可能产生"过冲",在数据精确性要求高的场景使用折线。

  3. 面积填充透明度:面积填充使用低透明度(10%-30%),避免遮挡其他系列。

  4. 实时数据缓冲:实时折线图维护固定长度的数据缓冲区,超出时移除最早的数据。

  5. 缩放性能:大数据量缩放时,只渲染视口内的数据点,避免全量绘制。

  6. 时间轴格式:根据缩放级别动态调整时间标签格式(年/月/日/时/分)。

  7. 缺失数据处理:时间序列中的缺失数据应使用null表示,绘制时跳过而非连接。

  8. 多系列层次:先绘制面积再绘制线条,确保线条始终在面积之上。

代码规范示例

代码示例

// 好的做法:折线图配置对象
const lineChartConfig = {
  margin: { top: 25, right: 25, bottom: 45, left: 55 },
  line: {
    width: 2.5,
    join: 'round',
    cap: 'round',
    smooth: true,
    tension: 0.3
  },
  point: {
    radius: 4,
    hoverRadius: 6,
    showThreshold: 50  // 数据点少于50个时显示
  },
  area: {
    enabled: true,
    opacity: 0.15
  },
  animation: {
    duration: 1200,
    easing: 'easeOutCubic'
  },
  zoom: {
    enabled: true,
    minRange: 10,
    maxRange: null
  }
};

// 好的做法:处理缺失数据
function drawLineWithGaps(ctx, points) {
  let drawing = false;
  points.forEach((p, i) => {
    if (p === null) {
      drawing = false;
      return;
    }
    if (!drawing) {
      ctx.moveTo(p.x, p.y);
      drawing = true;
    } else {
      ctx.lineTo(p.x, p.y);
    }
  });
}

常见问题与解决方案

问题1:平滑曲线过冲

原因:贝塞尔曲线的控制点计算不当,导致曲线超出数据范围。

解决方案:使用单调三次插值(Monotone Cubic Interpolation),保证曲线不超出相邻数据点的范围。

问题2:实时图表内存泄漏

原因:不断添加数据点但未移除旧数据,数组无限增长。

解决方案

代码示例

function addDataPoint(buffer, newValue, maxLength) {
  buffer.push(newValue);
  if (buffer.length > maxLength) {
    buffer.shift(); // 移除最早的数据
  }
}

问题3:多系列折线图交叉时难以追踪

原因:多条线交叉时,用户难以追踪特定系列。

解决方案

  • 鼠标悬停时高亮当前系列,淡化其他系列

  • 提供图例点击切换系列显示/隐藏

  • 使用不同的线型(实线、虚线、点线)辅助区分

问题4:缩放后X轴标签重叠

原因:缩放后标签间距变小,标签文字重叠。

解决方案

代码示例

function calculateLabelInterval(visibleCount, maxWidth) {
  const labelWidth = 50; // 估计标签宽度
  const maxLabels = Math.floor(maxWidth / labelWidth);
  return Math.max(1, Math.ceil(visibleCount / maxLabels));
}

总结

本教程全面讲解了折线图的各类实现:

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

  2. 多系列折线图:多系列对比展示,支持图例切换

  3. 面积填充:半透明渐变填充,增强趋势感知

  4. 平滑曲线:贝塞尔曲线平滑算法,视觉更柔和

  5. 数据点标记:根据数据量动态显示/隐藏

  6. 缩放与平移:鼠标滚轮缩放和拖拽平移,适合大数据量浏览

  7. 实时数据更新:固定长度缓冲区、定时更新、动画过渡

折线图是时间序列分析的核心工具,掌握其各类变体和交互实现对于构建数据可视化应用至关重要。

常见问题

平滑曲线过冲

贝塞尔曲线的控制点计算不当,导致曲线超出数据范围。

实时图表内存泄漏

- 鼠标悬停时高亮当前系列,淡化其他系列 - 提供图例点击切换系列显示/隐藏 - 使用不同的线型(实线、虚线、点线)辅助区分

多系列折线图交叉时难以追踪

多条线交叉时,用户难以追踪特定系列。

标签: 图表 动态更新 雷达图 WebSocket 折线图 内存管理 场景管理 Line Chart

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

本文涉及AI创作

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

list快速访问

上一篇: 数据可视化:柱状图实现 - 从入门到实践详解 下一篇: 数据可视化:饼图与环形图 - 从入门到实践详解

poll相关推荐