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

数据可视化:面积图与雷达图 - 从入门到实践详解

教程简介

面积图和雷达图是两种重要的数据可视化图表类型。面积图通过折线下方的填充区域强调数据的量级和趋势变化,堆叠面积图还能展示各部分的贡献和总量变化。雷达图则通过多轴放射状布局展示多维数据的对比,特别适合评估多个对象在多个指标上的综合表现。本教程将全面讲解面积图与雷达图的实现,包括面积图实现、堆叠面积图、雷达图实现、多维度对比、填充透明度和交互效果。

核心概念

面积图

面积图是折线图的变体,在折线下方填充颜色区域:

类型:

  1. 基础面积图:单系列面积填充,强调趋势量级

  2. 堆叠面积图:多系列堆叠,展示总量和各部分贡献

  3. 百分比堆叠面积图:每列总和为100%,展示各部分占比变化

适用场景:

  • 展示数据量级随时间的变化

  • 比较多个系列的贡献大小

  • 展示各部分占整体的比例变化

注意事项:

  • 系列数量不宜超过5个,否则难以区分

  • 堆叠面积图中系列顺序影响可读性

  • 面积填充使用半透明颜色,避免遮挡

雷达图

雷达图(又称蜘蛛图)使用放射状的多轴坐标系展示多维数据:

构成要素:

  • 多个从中心向外辐射的轴线,每个轴代表一个维度

  • 数据点在各轴上标记,连接成多边形

  • 多个数据系列可叠加在同一雷达图上

适用场景:

  • 多维性能评估(如产品评分、员工能力评估)

  • 多对象多维对比

  • 展示数据在各维度上的均衡性

注意事项:

  • 维度数量建议5-8个,过少无意义,过多难阅读

  • 维度顺序影响图形形状,需合理排列

  • 数值范围应统一,否则大值维度会主导图形

语法与用法

面积图绘制核心逻辑

代码示例

function drawAreaChart(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);

  // 计算数据点
  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();

  const grad = ctx.createLinearGradient(0, 0, 0, plotHeight);
  grad.addColorStop(0, data.color + '40');
  grad.addColorStop(1, data.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 = data.color;
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.restore();
}

雷达图绘制核心逻辑

代码示例

function drawRadarChart(ctx, data, config) {
  const { cx, cy, radius, dimensions } = config;
  const angleStep = (Math.PI * 2) / dimensions.length;
  const maxVal = 100; // 假设0-100范围

  // 绘制网格
  [0.2, 0.4, 0.6, 0.8, 1.0].forEach(scale => {
    ctx.beginPath();
    dimensions.forEach((_, i) => {
      const angle = -Math.PI / 2 + i * angleStep;
      const x = cx + Math.cos(angle) * radius * scale;
      const y = cy + Math.sin(angle) * radius * scale;
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath();
    ctx.strokeStyle = '#eee';
    ctx.stroke();
  });

  // 绘制轴线
  dimensions.forEach((dim, i) => {
    const angle = -Math.PI / 2 + i * angleStep;
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius);
    ctx.strokeStyle = '#ddd';
    ctx.stroke();
  });

  // 绘制数据多边形
  data.forEach(series => {
    ctx.beginPath();
    series.values.forEach((v, i) => {
      const angle = -Math.PI / 2 + i * angleStep;
      const r = (v / maxVal) * radius;
      const x = cx + Math.cos(angle) * r;
      const y = cy + Math.sin(angle) * r;
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath();
    ctx.fillStyle = series.color + '30';
    ctx.fill();
    ctx.strokeStyle = series.color;
    ctx.lineWidth = 2;
    ctx.stroke();
  });
}

代码示例

示例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; }
    .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; }
    canvas { width: 100%; cursor: crosshair; }
    .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-color { width: 14px; height: 14px; border-radius: 3px; }
  </style>
</head>
<body>
  <div class="chart-container">
    <div class="chart-header">
      <span class="chart-title">网站流量来源分析</span>
      <div class="toggle-group">
        <button class="toggle-btn active" onclick="setMode('stacked')">堆叠</button>
        <button class="toggle-btn" onclick="setMode('percent')">百分比</button>
      </div>
    </div>
    <canvas id="areaChart"></canvas>
    <div class="legend" id="legend"></div>
  </div>

  <script>
    const trafficData = {
      labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
      series: [
        { name: '自然搜索', color: '#4e79a7', data: [320,350,380,420,450,480,520,550,530,560,590,620], visible: true },
        { name: '社交媒体', color: '#f28e2b', data: [180,200,220,250,280,310,340,360,350,370,400,420], visible: true },
        { name: '直接访问', color: '#e15759', data: [150,160,170,180,190,200,210,220,215,225,240,250], visible: true },
        { name: '付费广告', color: '#59a14f', data: [100,120,140,160,180,200,220,240,230,250,270,290], visible: true },
        { name: '邮件营销', color: '#76b7b2', data: [50,55,60,65,70,75,80,85,82,88,92,98], visible: true }
      ]
    };

    let chartMode = 'stacked';
    let mouseX = -1;
    let animProgress = 0;

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

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

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

    function setMode(mode) {
      chartMode = mode;
      document.querySelectorAll('.toggle-btn').forEach((btn, i) => {
        btn.classList.toggle('active', (i === 0 && mode === 'stacked') || (i === 1 && mode === 'percent'));
      });
      animateChart();
    }

    function getStackedData() {
      const visible = trafficData.series.filter(s => s.visible);
      const len = trafficData.labels.length;
      const stacks = [];

      for (let i = 0; i < len; i++) {
        let cumValue = 0;
        const row = [];
        visible.forEach(s => {
          const raw = s.data[i];
          cumValue += raw;
          row.push({ bottom: cumValue - raw, top: cumValue, raw });
        });
        stacks.push(row);
      }

      if (chartMode === 'percent') {
        const totals = stacks.map(row => row[row.length - 1].top);
        stacks.forEach((row, i) => {
          const total = totals[i] || 1;
          let cumPct = 0;
          row.forEach(item => {
            const bottomPct = cumPct;
            const topPct = cumPct + (item.raw / total) * 100;
            item.bottom = bottomPct;
            item.top = topPct;
            cumPct = topPct;
          });
        });
      }

      return stacks;
    }

    function drawChart() {
      const visible = trafficData.series.filter(s => s.visible);
      if (visible.length === 0) return;

      const stacks = getStackedData();
      const maxVal = chartMode === 'percent' ? 100 : Math.max(...stacks.map(row => row[row.length - 1].top));
      const xStep = pw / (trafficData.labels.length - 1);

      ctx.clearRect(0, 0, displayW, displayH);
      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(chartMode === 'percent' ? v + '%' : v, -8, y);
      }

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

      // 绘制面积(从上到下)
      for (let si = visible.length - 1; si >= 0; si--) {
        const series = visible[si];
        const seriesIdx = trafficData.series.indexOf(series);

        // 上边界
        ctx.beginPath();
        for (let i = 0; i < trafficData.labels.length; i++) {
          const x = i * xStep;
          const y = ph - (stacks[i][si].top / maxVal) * ph;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        // 下边界(反向)
        for (let i = trafficData.labels.length - 1; i >= 0; i--) {
          const x = i * xStep;
          const y = ph - (stacks[i][si].bottom / maxVal) * ph;
          ctx.lineTo(x, y);
        }
        ctx.closePath();

        const grad = ctx.createLinearGradient(0, 0, 0, ph);
        grad.addColorStop(0, series.color + '80');
        grad.addColorStop(1, series.color + '30');
        ctx.fillStyle = grad;
        ctx.fill();

        // 上边界线
        ctx.beginPath();
        for (let i = 0; i < trafficData.labels.length; i++) {
          const x = i * xStep;
          const y = ph - (stacks[i][si].top / maxVal) * ph;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        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 < trafficData.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 lines = [trafficData.labels[idx]];
          visible.forEach((s, si) => {
            const val = s.data[idx];
            const pct = chartMode === 'percent'
              ? ` (${stacks[idx][si].top.toFixed(1) - stacks[idx][si].bottom.toFixed(1)}%)`
              : '';
            lines.push(`${s.name}: ${val}${pct}`);
          });

          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' : visible[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 animateChart() {
      const start = performance.now();
      function step(now) {
        animProgress = Math.min(1, (now - start) / 1000);
        animProgress = 1 - Math.pow(1 - animProgress, 3);
        drawChart();
        if (animProgress < 1) requestAnimationFrame(step);
      }
      requestAnimationFrame(step);
    }

    renderLegend();
    animateChart();
  </script>
</body>
</html>

示例2:雷达图多维对比

以下示例实现雷达图,支持多系列对比、悬停交互和动画效果。

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>雷达图多维对比</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #f5f7fa;
      display: flex;
      justify-content: center;
      padding: 30px 20px;
    }
    .chart-container {
      background: #fff;
      border-radius: 16px;
      padding: 30px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      width: 100%;
      max-width: 700px;
    }
    .chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 15px; }
    canvas { width: 100%; cursor: pointer; }
    .legend {
      display: flex;
      gap: 20px;
      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; }
  </style>
</head>
<body>
  <div class="chart-container">
    <h2 class="chart-title">产品能力雷达图</h2>
    <canvas id="radarChart"></canvas>
    <div class="legend" id="radarLegend"></div>
  </div>

  <script>
    const radarData = {
      dimensions: ['性能', '易用性', '安全性', '可扩展', '稳定性', '文档'],
      series: [
        { name: '产品A', color: '#4e79a7', data: [85, 70, 90, 75, 88, 65], visible: true },
        { name: '产品B', color: '#e15759', data: [70, 85, 75, 90, 72, 80], visible: true },
        { name: '产品C', color: '#59a14f', data: [60, 90, 65, 80, 85, 92], visible: true }
      ]
    };

    let hoveredSeries = -1;
    let animProgress = 0;

    const canvas = document.getElementById('radarChart');
    const dpr = window.devicePixelRatio || 1;
    const size = Math.min(550, canvas.parentElement.clientWidth - 60);
    canvas.width = size * dpr;
    canvas.height = size * dpr;
    canvas.style.width = size + 'px';
    canvas.style.height = size + 'px';
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);

    const cx = size / 2;
    const cy = size / 2;
    const radius = size * 0.36;
    const dimCount = radarData.dimensions.length;
    const angleStep = (Math.PI * 2) / dimCount;
    const maxVal = 100;

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

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

    function drawChart() {
      ctx.clearRect(0, 0, size, size);
      const visible = radarData.series.filter(s => s.visible);

      // 网格多边形
      [0.2, 0.4, 0.6, 0.8, 1.0].forEach(scale => {
        ctx.beginPath();
        for (let i = 0; i < dimCount; i++) {
          const angle = -Math.PI / 2 + i * angleStep;
          const x = cx + Math.cos(angle) * radius * scale;
          const y = cy + Math.sin(angle) * radius * scale;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.closePath();
        ctx.strokeStyle = '#eee';
        ctx.lineWidth = 1;
        ctx.stroke();

        // 刻度标签
        if (scale === 1 || scale === 0.5) {
          const angle = -Math.PI / 2;
          const lx = cx + Math.cos(angle) * radius * scale - 15;
          const ly = cy + Math.sin(angle) * radius * scale - 5;
          ctx.fillStyle = '#bbb';
          ctx.font = '10px sans-serif';
          ctx.textAlign = 'right';
          ctx.fillText(Math.round(maxVal * scale), lx, ly);
        }
      });

      // 轴线和标签
      for (let i = 0; i < dimCount; i++) {
        const angle = -Math.PI / 2 + i * angleStep;
        const ex = cx + Math.cos(angle) * radius;
        const ey = cy + Math.sin(angle) * radius;

        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.lineTo(ex, ey);
        ctx.strokeStyle = '#ddd';
        ctx.lineWidth = 1;
        ctx.stroke();

        // 维度标签
        const labelR = radius + 20;
        const lx = cx + Math.cos(angle) * labelR;
        const ly = cy + Math.sin(angle) * labelR;
        ctx.fillStyle = '#555';
        ctx.font = '13px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        // 调整标签位置避免重叠
        if (Math.abs(Math.cos(angle)) > 0.8) {
          ctx.textAlign = Math.cos(angle) > 0 ? 'left' : 'right';
        }
        ctx.fillText(radarData.dimensions[i], lx, ly);
      }

      // 数据多边形
      visible.forEach((series, si) => {
        const seriesIdx = radarData.series.indexOf(series);
        const isHovered = hoveredSeries === seriesIdx;
        const opacity = isHovered ? '50' : '25';

        ctx.beginPath();
        series.data.forEach((v, i) => {
          const angle = -Math.PI / 2 + i * angleStep;
          const r = (v / maxVal) * radius * animProgress;
          const x = cx + Math.cos(angle) * r;
          const y = cy + Math.sin(angle) * r;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        });
        ctx.closePath();
        ctx.fillStyle = series.color + opacity;
        ctx.fill();
        ctx.strokeStyle = series.color;
        ctx.lineWidth = isHovered ? 3 : 2;
        ctx.stroke();

        // 数据点
        series.data.forEach((v, i) => {
          const angle = -Math.PI / 2 + i * angleStep;
          const r = (v / maxVal) * radius * animProgress;
          const x = cx + Math.cos(angle) * r;
          const y = cy + Math.sin(angle) * r;

          ctx.beginPath();
          ctx.arc(x, y, isHovered ? 5 : 3.5, 0, Math.PI * 2);
          ctx.fillStyle = '#fff';
          ctx.fill();
          ctx.strokeStyle = series.color;
          ctx.lineWidth = 2;
          ctx.stroke();

          // 悬停时显示数值
          if (isHovered) {
            ctx.fillStyle = series.color;
            ctx.font = 'bold 11px sans-serif';
            ctx.textAlign = 'center';
            ctx.fillText(v, x, y - 12);
          }
        });
      });
    }

    // 交互
    canvas.addEventListener('mousemove', (e) => {
      const rect = canvas.getBoundingClientRect();
      const mx = (e.clientX - rect.left) * (size / rect.width) - cx;
      const my = (e.clientY - rect.top) * (size / rect.height) - cy;
      const dist = Math.sqrt(mx * mx + my * my);

      if (dist > radius + 20) {
        if (hoveredSeries !== -1) { hoveredSeries = -1; drawChart(); }
        return;
      }

      // 找到最近的系列
      let found = -1;
      let minDist = 30;
      radarData.series.forEach((series, si) => {
        if (!series.visible) return;
        series.data.forEach((v, i) => {
          const angle = -Math.PI / 2 + i * angleStep;
          const r = (v / maxVal) * radius;
          const px = Math.cos(angle) * r;
          const py = Math.sin(angle) * r;
          const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
          if (d < minDist) { minDist = d; found = si; }
        });
      });

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

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

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

    renderLegend();
    requestAnimationFrame(animate);
  </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;
    }
    h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
    .panel {
      display: flex;
      gap: 20px;
      max-width: 1100px;
      margin: 0 auto;
      flex-wrap: wrap;
    }
    .card {
      background: #fff;
      border-radius: 16px;
      padding: 25px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      flex: 1;
      min-width: 400px;
    }
    .card h2 { text-align: center; color: #34495e; margin-bottom: 15px; font-size: 16px; }
    canvas { width: 100%; }
  </style>
</head>
<body>
  <h1>数据分析面板</h1>
  <div class="panel">
    <div class="card">
      <h2>月度收入趋势(面积图)</h2>
      <canvas id="miniArea"></canvas>
    </div>
    <div class="card">
      <h2>能力评估(雷达图)</h2>
      <canvas id="miniRadar"></canvas>
    </div>
  </div>

  <script>
    // 迷你面积图
    (function() {
      const canvas = document.getElementById('miniArea');
      const dpr = window.devicePixelRatio || 1;
      const w = canvas.parentElement.clientWidth - 50;
      const h = 250;
      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);

      const data = [42, 55, 48, 62, 58, 72, 68, 85, 78, 92, 88, 95];
      const labels = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
      const m = { top: 15, right: 15, bottom: 30, left: 40 };
      const pw = w - m.left - m.right;
      const ph = h - m.top - m.bottom;
      const maxV = 100;
      const xStep = pw / (data.length - 1);

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

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

      // 面积
      const points = data.map((v, i) => ({ x: i * xStep, y: ph - (v / maxV) * 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, '#4e79a740');
      grad.addColorStop(1, '#4e79a708');
      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 = 2; ctx.lineJoin = 'round'; ctx.stroke();

      // 数据点
      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();
      });

      // X标签
      labels.forEach((l, i) => {
        ctx.fillStyle = '#bbb'; ctx.font = '10px sans-serif';
        ctx.textAlign = 'center'; ctx.fillText(l, i * xStep, ph + 15);
      });

      ctx.restore();
    })();

    // 迷你雷达图
    (function() {
      const canvas = document.getElementById('miniRadar');
      const dpr = window.devicePixelRatio || 1;
      const s = Math.min(400, canvas.parentElement.clientWidth - 50);
      canvas.width = s * dpr; canvas.height = s * dpr;
      canvas.style.width = s + 'px'; canvas.style.height = s + 'px';
      const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);

      const cx = s / 2, cy = s / 2, r = s * 0.34;
      const dims = ['技术', '沟通', '领导力', '创新', '执行', '协作'];
      const series = [
        { name: '当前', color: '#4e79a7', data: [85, 70, 60, 80, 90, 75] },
        { name: '目标', color: '#e15759', data: [90, 85, 80, 90, 95, 90] }
      ];
      const n = dims.length;
      const step = Math.PI * 2 / n;

      // 网格
      [0.25, 0.5, 0.75, 1].forEach(sc => {
        ctx.beginPath();
        for (let i = 0; i < n; i++) {
          const a = -Math.PI / 2 + i * step;
          const x = cx + Math.cos(a) * r * sc;
          const y = cy + Math.sin(a) * r * sc;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.closePath(); ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.stroke();
      });

      // 轴线和标签
      for (let i = 0; i < n; i++) {
        const a = -Math.PI / 2 + i * step;
        ctx.beginPath(); ctx.moveTo(cx, cy);
        ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
        ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; ctx.stroke();

        const lr = r + 18;
        ctx.fillStyle = '#555'; ctx.font = '12px sans-serif';
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        if (Math.abs(Math.cos(a)) > 0.8) ctx.textAlign = Math.cos(a) > 0 ? 'left' : 'right';
        ctx.fillText(dims[i], cx + Math.cos(a) * lr, cy + Math.sin(a) * lr);
      }

      // 数据
      series.forEach(s => {
        ctx.beginPath();
        s.data.forEach((v, i) => {
          const a = -Math.PI / 2 + i * step;
          const x = cx + Math.cos(a) * r * v / 100;
          const y = cy + Math.sin(a) * r * v / 100;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        });
        ctx.closePath();
        ctx.fillStyle = s.color + '25'; ctx.fill();
        ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.stroke();

        s.data.forEach((v, i) => {
          const a = -Math.PI / 2 + i * step;
          const x = cx + Math.cos(a) * r * v / 100;
          const y = cy + Math.sin(a) * r * v / 100;
          ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
          ctx.fillStyle = '#fff'; ctx.fill();
          ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.stroke();
        });
      });

      // 图例
      series.forEach((s, i) => {
        const lx = cx - 60 + i * 80;
        ctx.strokeStyle = s.color; ctx.lineWidth = 3;
        ctx.beginPath(); ctx.moveTo(lx, s * 0 + 15); ctx.lineTo(lx + 15, 15); ctx.stroke();
        ctx.fillStyle = '#666'; ctx.font = '11px sans-serif';
        ctx.textAlign = 'left'; ctx.fillText(s.name, lx + 20, 19);
      });
    })();
  </script>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge IE11
Canvas arc 4+ 3.6+ 4+ 12+ 9+
createLinearGradient 4+ 3.6+ 4+ 12+ 9+
roundRect 99+ 112+ 16+ 99+ 不支持
setLineDash 4+ 27+ 6+ 12+ 11+
globalAlpha 4+ 3.6+ 4+ 12+ 9+

注意事项与最佳实践

  1. 面积图系列数量:堆叠面积图系列不超过5个,否则难以区分和阅读。

  2. 填充透明度:面积填充使用15%-40%透明度,确保重叠区域可辨识。

  3. 堆叠顺序:将变化最大的系列放在底部,便于观察趋势。

  4. 雷达图维度数:5-8个维度效果最佳,过少缺乏意义,过多难以阅读。

  5. 雷达图数值范围:所有维度使用相同的数值范围(如0-100),避免大值维度主导图形。

  6. 雷达图维度排列:将相关维度相邻排列,便于观察关联性。

  7. 百分比堆叠:当关注占比变化而非绝对值时,使用百分比堆叠面积图。

  8. 基线选择:面积图Y轴从零开始,否则填充面积会产生误导。

代码规范示例

代码示例

// 好的做法:面积图配置
const areaChartConfig = {
  margin: { top: 25, right: 25, bottom: 45, left: 55 },
  area: {
    fillOpacity: 0.3,
    strokeWidth: 2,
    gradient: true,
    gradientStops: [
      { offset: 0, opacity: 0.4 },
      { offset: 1, opacity: 0.05 }
    ]
  },
  stack: {
    mode: 'stacked', // 'stacked' | 'percent' | 'none'
    order: 'reverse'  // 'reverse' | 'original' | 'ascending' | 'descending'
  },
  radar: {
    gridLevels: 5,
    gridOpacity: 0.15,
    axisOpacity: 0.2,
    fillOpacity: 0.2,
    strokeWidth: 2,
    pointRadius: 4
  }
};

常见问题与解决方案

问题1:堆叠面积图系列顺序影响可读性

解决方案:将最稳定(变化最小)的系列放在底部,变化最大的放在顶部,便于观察趋势。

问题2:雷达图形状受维度顺序影响

解决方案:将相关维度相邻排列;提供多种排列方式供用户选择;使用平均值作为参考线。

问题3:面积图Y轴不从零导致误导

解决方案:面积图必须从零开始填充。如果只需展示趋势不需展示量级,使用折线图而非面积图。

问题4:雷达图维度量纲不同

解决方案:对所有维度进行归一化处理(如0-100分),确保各维度在图形上的权重一致。

总结

本教程全面讲解了面积图与雷达图的各类实现:

  1. 面积图:基础面积填充、渐变效果、折线叠加

  2. 堆叠面积图:多系列堆叠、总量展示、各部分贡献

  3. 百分比堆叠:占比变化展示、消除总量差异

  4. 雷达图:多维数据展示、多边形绘制、网格和轴线

  5. 多维度对比:多系列叠加、颜色区分、交互高亮

  6. 填充透明度:半透明填充、渐变效果、重叠可辨识

  7. 交互效果:十字准线、tooltip、悬停高亮

面积图和雷达图是展示趋势量级和多维对比的重要工具,掌握其实现对于构建综合数据分析应用至关重要。

常见问题

什么是面积图与雷达图?

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

如何学习面积图与雷达图的实际应用?

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

面积图与雷达图有哪些注意事项?

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

面积图与雷达图适合初学者吗?

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

面积图与雷达图的核心要点是什么?

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

标签: 图表 雷达图 折线图 内存管理 Line Chart 面积图 帧率优化 趋势分析

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

本文涉及AI创作

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

list快速访问

上一篇: 数据可视化:散点图与气泡图 - 从入门到实践详解 下一篇: 数据可视化:数据表格设计 - 从入门到实践详解

poll相关推荐