SVG数据可视化组件基础教程12:环形图可视化组件

191 阅读5分钟

12.gif

我是设计师邱兴,一个学习前端的设计师,今天给大家制作一个用SVG实现的环形图可视化组件,SVG相较于Echart来说制作简单,但是效果可以非常丰富。

SVG 环形图可视化组件制作教程

一、目标

通过 HTML、CSS 和 JavaScript 创建一个交互式的 SVG 环形图可视化组件,实现以下功能:

  1. 使用 SVG 绘制环形图,并支持动画效果。
  2. 通过滑块动态调整各部分比例。
  3. 显示各部分的图例和提示信息。

二、所需工具与准备

  1. 工具
    • 一个文本编辑器(如 Notepad++、VS Code 等)。
    • 浏览器(用于预览效果)。
  2. 基础准备
    • 确保你对 HTML、CSS 和 JavaScript 有一定的了解。
    • 确保你对 SVG 的基本语法有一定了解。

三、代码分析与操作步骤

1. 创建 HTML 结构

创建一个 HTML 文件(如 Lesson12.html)并设置基本的 HTML 结构:

<!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>
    /* 样式部分 */
  </style>
</head>
<body>
  <h1>环形图可视化组件</h1>
  <div class="container">
    <div class="ring-svg-container">
      <svg id="ring-svg" viewBox="0 0 340 340"></svg>
      <div id="tooltip"></div>
    </div>
    <div class="controls">
      <h2>调整各部分比例</h2>
      <div id="sliders"></div>
      <ul id="legend"></ul>
    </div>
  </div>
  <script>
    // JavaScript 部分
  </script>
</body>
</html>

2. 添加 CSS 样式

<style> 标签中,添加以下 CSS 样式:

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background: #f4f7f9;
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
h1 {
  color: #333;
  font-weight: 300;
}
.container {
  display: flex;
  gap: 40px;
  margin-top: 20px;
}
.ring-svg-container {
  position: relative;
  width: 340px;
  height: 340px;
}
#ring-svg {
  width: 340px;
  height: 340px;
  display: block;
}
#tooltip {
  position: absolute;
  background: rgba(0,0,0,0.8);
  color: #fff;
  padding: 8px 12px;
  border-radius: 4px;
  font-size: 14px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s, transform 0.2s;
  transform: translate(-50%, -120%);
  white-space: nowrap;
}
.controls {
  width: 300px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.08);
  padding: 20px;
}
.controls h2 {
  margin-top: 0;
  font-size: 18px;
  font-weight: 500;
  color: #333;
  border-bottom: 1px solid #eee;
  padding-bottom: 10px;
  margin-bottom: 20px;
}
.control-group {
  margin-bottom: 20px;
}
.control-group label {
  display: block;
  margin-bottom: 8px;
  color: #555;
  font-size: 14px;
}
.control-group input[type="range"] {
  width: 100%;
  cursor: pointer;
}
.control-group span {
  float: right;
  font-weight: bold;
  color: #333;
}
#legend {
  margin-top: 20px;
  list-style: none;
  padding: 0;
}
.legend-color {
  width: 18px;
  height: 18px;
  margin-right: 10px;
  border-radius: 4px;
  border: 1px solid rgba(0,0,0,0.1);
}
  • body:设置页面字体、背景颜色和布局。
  • .container:设置容器的布局和间距。
  • .ring-svg-container:设置 SVG 容器的宽度和高度。
  • #tooltip:设置提示框的样式。
  • .controls:设置控制面板的样式。
  • .control-group:设置滑块和标签的样式。
  • #legend:设置图例的样式。

3. 编写 JavaScript 代码

<script> 标签中,添加以下 JavaScript 代码来实现环形图可视化和交互功能:

let ringData = [
  { name: 'A', value: 40, color: '#f1c40f' },
  { name: 'B', value: 30, color: '#e67e22' },
  { name: 'C', value: 20, color: '#3498db' },
  { name: 'D', value: 10, color: '#9b59b6' }
];

const svg = document.getElementById('ring-svg');
const slidersContainer = document.getElementById('sliders');
const legendContainer = document.getElementById('legend');
const tooltip = document.getElementById('tooltip');
const svgContainer = document.querySelector('.ring-svg-container');
const cx = 170, cy = 170, rOuter = 130, rInner = 80;
let animationProgress = 0;
let animationFrame = null;

function polarToXY(cx, cy, r, angle) {
  let rad = (angle - 90) * Math.PI / 180;
  return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}

function describeArc(cx, cy, r1, r2, startAngle, endAngle) {
  const [x1, y1] = polarToXY(cx, cy, rOuter, endAngle);
  const [x2, y2] = polarToXY(cx, cy, rOuter, startAngle);
  const [x3, y3] = polarToXY(cx, cy, rInner, startAngle);
  const [x4, y4] = polarToXY(cx, cy, rInner, endAngle);
  const largeArc = endAngle - startAngle > 180 ? 1 : 0;
  return [
    `M${x2},${y2}`,
    `A${rOuter},${rOuter} 0 ${largeArc} 1 ${x1},${y1}`,
    `L${x4},${y4}`,
    `A${rInner},${rInner} 0 ${largeArc} 0 ${x3},${y3}`,
    'Z'
  ].join(' ');
}

function drawRingChart(progress = 1) {
  svg.innerHTML = '';
  for (let i = 0; i < 100; i += 10) {
    let angle = i * 3.6;
    let [x1, y1] = polarToXY(cx, cy, rOuter + 6, angle);
    let [x2, y2] = polarToXY(cx, cy, rOuter + 18, angle);
    let tick = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    tick.setAttribute('x1', x1);
    tick.setAttribute('y1', y1);
    tick.setAttribute('x2', x2);
    tick.setAttribute('y2', y2);
    tick.setAttribute('stroke', '#ddd');
    tick.setAttribute('stroke-width', 2);
    svg.appendChild(tick);
  }
  let total = ringData.reduce((sum, d) => sum + d.value, 0);
  let startAngle = 0;
  for (let i = 0; i < ringData.length; i++) {
    let d = ringData[i];
    let angle = d.value / total * 360 * progress;
    let endAngle = startAngle + angle;
    if (angle > 0.1) {
      let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('d', describeArc(cx, cy, rOuter, rInner, startAngle, endAngle));
      path.setAttribute('fill', d.color);
      path.setAttribute('stroke', '#fff');
      path.setAttribute('stroke-width', 3);
      path.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,0.08))';
      path.dataset.index = i;
      svg.appendChild(path);
      let [sx, sy] = polarToXY(cx, cy, rOuter, endAngle);
      let [ex, ey] = polarToXY(cx, cy, rInner, endAngle);
      let sep = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      sep.setAttribute('x1', sx);
      sep.setAttribute('y1', sy);
      sep.setAttribute('x2', ex);
      sep.setAttribute('y2', ey);
      sep.setAttribute('stroke', '#fff');
      sep.setAttribute('stroke-width', 2);
      svg.appendChild(sep);
      path.addEventListener('mousemove', (e) => {
        tooltip.style.opacity = '1';
        const containerBox = svgContainer.getBoundingClientRect();
        tooltip.style.left = `${e.clientX - containerBox.left}px`;
        tooltip.style.top = `${e.clientY - containerBox.top}px`;
        tooltip.innerHTML = `${d.name}: ${d.value.toFixed(1)}%`;
        path.setAttribute('filter', 'drop-shadow(0 0 12px #fff)');
      });
      path.addEventListener('mouseleave', () => {
        tooltip.style.opacity = '0';
        path.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,0.08))';
      });
    }
    startAngle = endAngle;
  }
  let centerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  centerCircle.setAttribute('cx', cx);
  centerCircle.setAttribute('cy', cy);
  centerCircle.setAttribute('r', rInner - 16);
  centerCircle.setAttribute('fill', '#fff');
  centerCircle.setAttribute('stroke', '#eee');
  centerCircle.setAttribute('stroke-width', 2);
  svg.appendChild(centerCircle);
}

function animateRingChart() {
  animationProgress = 0;
  function step() {
    animationProgress += 0.04;
    if (animationProgress > 1) animationProgress = 1;
    drawRingChart(animationProgress);
    if (animationProgress < 1) {
      animationFrame = requestAnimationFrame(step);
    }
  }
  if (animationFrame) cancelAnimationFrame(animationFrame);
  step();
}

function createControls() {
  slidersContainer.innerHTML = '';
  legendContainer.innerHTML = '';
  for (let i = 0; i < ringData.length; i++) {
    let d = ringData[i];
    let group = document.createElement('div');
    group.className = 'control-group';
    let label = document.createElement('label');
    label.setAttribute('for', `part${i}-slider`);
    let valueSpan = document.createElement('span');
    valueSpan.id = `part${i}-value`;
    valueSpan.textContent = `${d.value}%`;
    label.textContent = d.name;
    label.appendChild(valueSpan);
    let slider = document.createElement('input');
    slider.type = 'range';
    slider.id = `part${i}-slider`;
    slider.min = 0;
    slider.max = 100;
    slider.value = d.value;
    slider.step = 0.1;
    slider.dataset.index = i;
    slider.addEventListener('input', handleSliderInput);
    group.appendChild(label);
    group.appendChild(slider);
    slidersContainer.appendChild(group);
    let li = document.createElement('li');
    let colorBox = document.createElement('div');
    colorBox.className = 'legend-color';
    colorBox.style.backgroundColor = d.color;
    li.appendChild(colorBox);
    let legendText = document.createElement('span');
    legendText.textContent = d.name;
    li.appendChild(legendText);
    legendContainer.appendChild(li);
  }
}

function handleSliderInput(e) {
  let idx = parseInt(e.target.dataset.index);
  let newValue = parseFloat(e.target.value);
  let oldValue = ringData[idx].value;
  let diff = newValue - oldValue;
  ringData[idx].value = newValue;
  let others = ringData.map((d, i) => i).filter(i => i !== idx);
  let totalOthers = others.reduce((sum, i) => sum + ringData[i].value, 0);
  if (totalOthers > 0) {
    for (let i of others) {
      let prop = ringData[i].value / totalOthers;
      ringData[i].value -= diff * prop;
    }
  }
  normalizeData();
  updateControls();
  drawRingChart(1);
}

function updateControls() {
  for (let i = 0; i < ringData.length; i++) {
    let slider = document.getElementById(`part${i}-slider`);
    let valueSpan = document.getElementById(`part${i}-value`);
    if (slider) slider.value = ringData[i].value;
    if (valueSpan) valueSpan.textContent = `${ringData[i].value.toFixed(1)}%`;
  }
}

function normalizeData() {
  for (let d of ringData) {
    if (d.value < 0) d.value = 0;
  }
  let total = ringData.reduce((sum, d) => sum + d.value, 0);
  if (total === 0) return;
  for (let d of ringData) {
    d.value = d.value / total * 100;
  }
}

normalizeData();
createControls();
updateControls();
drawRingChart(1);

4. 关键参数说明

  • ringData:存储环形图各部分的数据,包括名称、比例和颜色。
  • cxcy:环形图的中心坐标。
  • rOuterrInner:环形图的外半径和内半径。
  • animationProgress:动画进度,范围为 0 到 1。
  • describeArc():生成环形扇形路径的函数。
  • drawRingChart():绘制环形图的函数。
  • animateRingChart():环形图动画函数。
  • createControls():创建滑块和图例的函数。
  • handleSliderInput():处理滑块输入事件,动态调整各部分比例并保持总和为 100%。
  • updateControls():更新所有滑块和数值显示。
  • normalizeData():标准化数据,确保各部分比例总和为 100%。

5. 完整代码

将上述代码整合到一个 HTML 文件中,完整的代码如下:

<!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>
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      background: #f4f7f9;
      margin: 0;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
    }
    h1 {
      color: #333;
      font-weight: 300;
    }
    .container {
      display: flex;
      gap: 40px;
      margin-top: 20px;
    }
    .ring-svg-container {
      position: relative;
      width: 340px;
      height: 340px;
    }
    #ring-svg {
      width: 340px;
      height: 340px;
      display: block;
    }
    #tooltip {
      position: absolute;
      background: rgba(0,0,0,0.8);
      color: #fff;
      padding: 8px 12px;
      border-radius: 4px;
      font-size: 14px;
      pointer-events: none;
      opacity: 0;
      transition: opacity 0.2s, transform 0.2s;
      transform: translate(-50%, -120%);
      white-space: nowrap;
    }
    .controls {
      width: 300px;
      background: #fff;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.08);
      padding: 20px;
    }
    .controls h2 {
      margin-top: 0;
      font-size: 18px;
      font-weight: 500;
      color: #333;
      border-bottom: 1px solid #eee;
      padding-bottom: 10px;
      margin-bottom: 20px;
    }
    .control-group {
      margin-bottom: 20px;
    }
    .control-group label {
      display: block;
      margin-bottom: 8px;
      color: #555;
      font-size: 14px;
    }
    .control-group input[type="range"] {
      width: 100%;
      cursor: pointer;
    }
    .control-group span {
      float: right;
      font-weight: bold;
      color: #333;
    }
    #legend {
      margin-top: 20px;
      list-style: none;
      padding: 0;
    }
    .legend-color {
      width: 18px;
      height: 18px;
      margin-right: 10px;
      border-radius: 4px;
      border: 1px solid rgba(0,0,0,0.1);
    }
  </style>
</head>
<body>
  <h1>环形图可视化组件</h1>
  <div class="container">
    <div class="ring-svg-container">
      <svg id="ring-svg" viewBox="0 0 340 340"></svg>
      <div id="tooltip"></div>
    </div>
    <div class="controls">
      <h2>调整各部分比例</h2>
      <div id="sliders"></div>
      <ul id="legend"></ul>
    </div>
  </div>
  <script>
    let ringData = [
      { name: 'A', value: 40, color: '#f1c40f' },
      { name: 'B', value: 30, color: '#e67e22' },
      { name: 'C', value: 20, color: '#3498db' },
      { name: 'D', value: 10, color: '#9b59b6' }
    ];

    const svg = document.getElementById('ring-svg');
    const slidersContainer = document.getElementById('sliders');
    const legendContainer = document.getElementById('legend');
    const tooltip = document.getElementById('tooltip');
    const svgContainer = document.querySelector('.ring-svg-container');
    const cx = 170, cy = 170, rOuter = 130, rInner = 80;
    let animationProgress = 0;
    let animationFrame = null;

    function polarToXY(cx, cy, r, angle) {
      let rad = (angle - 90) * Math.PI / 180;
      return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
    }

    function describeArc(cx, cy, r1, r2, startAngle, endAngle) {
      const [x1, y1] = polarToXY(cx, cy, rOuter, endAngle);
      const [x2, y2] = polarToXY(cx, cy, rOuter, startAngle);
      const [x3, y3] = polarToXY(cx, cy, rInner, startAngle);
      const [x4, y4] = polarToXY(cx, cy, rInner, endAngle);
      const largeArc = endAngle - startAngle > 180 ? 1 : 0;
      return [
        `M${x2},${y2}`,
        `A${rOuter},${rOuter} 0 ${largeArc} 1 ${x1},${y1}`,
        `L${x4},${y4}`,
        `A${rInner},${rInner} 0 ${largeArc} 0 ${x3},${y3}`,
        'Z'
      ].join(' ');
    }

    function drawRingChart(progress = 1) {
      svg.innerHTML = '';
      for (let i = 0; i < 100; i += 10) {
        let angle = i * 3.6;
        let [x1, y1] = polarToXY(cx, cy, rOuter + 6, angle);
        let [x2, y2] = polarToXY(cx, cy, rOuter + 18, angle);
        let tick = document.createElementNS('http://www.w3.org/2000/svg', 'line');
        tick.setAttribute('x1', x1);
        tick.setAttribute('y1', y1);
        tick.setAttribute('x2', x2);
        tick.setAttribute('y2', y2);
        tick.setAttribute('stroke', '#ddd');
        tick.setAttribute('stroke-width', 2);
        svg.appendChild(tick);
      }
      let total = ringData.reduce((sum, d) => sum + d.value, 0);
      let startAngle = 0;
      for (let i = 0; i < ringData.length; i++) {
        let d = ringData[i];
        let angle = d.value / total * 360 * progress;
        let endAngle = startAngle + angle;
        if (angle > 0.1) {
          let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
          path.setAttribute('d', describeArc(cx, cy, rOuter, rInner, startAngle, endAngle));
          path.setAttribute('fill', d.color);
          path.setAttribute('stroke', '#fff');
          path.setAttribute('stroke-width', 3);
          path.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,0.08))';
          path.dataset.index = i;
          svg.appendChild(path);
          let [sx, sy] = polarToXY(cx, cy, rOuter, endAngle);
          let [ex, ey] = polarToXY(cx, cy, rInner, endAngle);
          let sep = document.createElementNS('http://www.w3.org/2000/svg', 'line');
          sep.setAttribute('x1', sx);
          sep.setAttribute('y1', sy);
          sep.setAttribute('x2', ex);
          sep.setAttribute('y2', ey);
          sep.setAttribute('stroke', '#fff');
          sep.setAttribute('stroke-width', 2);
          svg.appendChild(sep);
          path.addEventListener('mousemove', (e) => {
            tooltip.style.opacity = '1';
            const containerBox = svgContainer.getBoundingClientRect();
            tooltip.style.left = `${e.clientX - containerBox.left}px`;
            tooltip.style.top = `${e.clientY - containerBox.top}px`;
            tooltip.innerHTML = `${d.name}: ${d.value.toFixed(1)}%`;
            path.setAttribute('filter', 'drop-shadow(0 0 12px #fff)');
          });
          path.addEventListener('mouseleave', () => {
            tooltip.style.opacity = '0';
            path.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,0.08))';
          });
        }
        startAngle = endAngle;
      }
      let centerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
      centerCircle.setAttribute('cx', cx);
      centerCircle.setAttribute('cy', cy);
      centerCircle.setAttribute('r', rInner - 16);
      centerCircle.setAttribute('fill', '#fff');
      centerCircle.setAttribute('stroke', '#eee');
      centerCircle.setAttribute('stroke-width', 2);
      svg.appendChild(centerCircle);
    }

    function createControls() {
      slidersContainer.innerHTML = '';
      legendContainer.innerHTML = '';
      for (let i = 0; i < ringData.length; i++) {
        let d = ringData[i];
        let group = document.createElement('div');
        group.className = 'control-group';
        let label = document.createElement('label');
        label.setAttribute('for', `part${i}-slider`);
        let valueSpan = document.createElement('span');
        valueSpan.id = `part${i}-value`;
        valueSpan.textContent = `${d.value}%`;
        label.textContent = d.name;
        label.appendChild(valueSpan);
        let slider = document.createElement('input');
        slider.type = 'range';
        slider.id = `part${i}-slider`;
        slider.min = 0;
        slider.max = 100;
        slider.value = d.value;
        slider.step = 0.1;
        slider.dataset.index = i;
        slider.addEventListener('input', handleSliderInput);
        group.appendChild(label);
        group.appendChild(slider);
        slidersContainer.appendChild(group);
        let li = document.createElement('li');
        let colorBox = document.createElement('div');
        colorBox.className = 'legend-color';
        colorBox.style.backgroundColor = d.color;
        li.appendChild(colorBox);
        let legendText = document.createElement('span');
        legendText.textContent = d.name;
        li.appendChild(legendText);
        legendContainer.appendChild(li);
      }
    }

    function handleSliderInput(e) {
      let idx = parseInt(e.target.dataset.index);
      let newValue = parseFloat(e.target.value);
      let oldValue = ringData[idx].value;
      let diff = newValue - oldValue;
      ringData[idx].value = newValue;
      let others = ringData.map((d, i) => i).filter(i => i !== idx);
      let totalOthers = others.reduce((sum, i) => sum + ringData[i].value, 0);
      if (totalOthers > 0) {
        for (let i of others) {
          let prop = ringData[i].value / totalOthers;
          ringData[i].value -= diff * prop;
        }
      }
      normalizeData();
      updateControls();
      drawRingChart(1);
    }

    function updateControls() {
      for (let i = 0; i < ringData.length; i++) {
        let slider = document.getElementById(`part${i}-slider`);
        let valueSpan = document.getElementById(`part${i}-value`);
        if (slider) slider.value = ringData[i].value;
        if (valueSpan) valueSpan.textContent = `${ringData[i].value.toFixed(1)}%`;
      }
    }

    function normalizeData() {
      for (let d of ringData) {
        if (d.value < 0) d.value = 0;
      }
      let total = ringData.reduce((sum, d) => sum + d.value, 0);
      if (total === 0) return;
      for (let d of ringData) {
        d.value = d.value / total * 100;
      }
    }

    normalizeData();
    createControls();
    updateControls();
    drawRingChart(1);
  </script>
</body>
</html>

四、总结

通过以上步骤,你可以创建一个交互式的 SVG 环形图可视化组件。这个组件使用 SVG 绘制环形图,通过滑块动态调整各部分比例,并显示各部分的图例和提示信息。你可以通过调整代码中的参数来改变环形图的外观和行为。希望这个教程对你有所帮助!

以上制作的是一个最简单的一个带刻度的仪表盘,我还录制了一个更加美观的带刻度的仪表盘的视频教程,有兴趣的小伙伴可以点击查看。

封面1.png