SVG数据可视化组件基础教程11:金字塔可视化组件

153 阅读4分钟

11.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文件(如Lesson11.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="pyramid-svg-container">
      <svg id="pyramid-svg" viewBox="0 0 320 400"></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;
}
.pyramid-svg-container {
  position: relative;
  width: 320px;
  height: 400px;
}
#pyramid-svg {
  width: 320px;
  height: 400px;
  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:设置容器的布局和间距。
  • .pyramid-svg-container:设置SVG容器的宽度和高度。
  • #tooltip:设置提示框的样式。
  • .controls:设置控制面板的样式。
  • .control-group:设置滑块和标签的样式。
  • #legend:设置图例的样式。

3. 编写JavaScript代码

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

let pyramidData = [
  { name: '第1层', value: 40, color: '#f1c40f' },
  { name: '第2层', value: 30, color: '#e67e22' },
  { name: '第3层', value: 20, color: '#3498db' },
  { name: '第4层', value: 10, color: '#9b59b6' }
];

const svg = document.getElementById('pyramid-svg');
const slidersContainer = document.getElementById('sliders');
const legendContainer = document.getElementById('legend');
const tooltip = document.getElementById('tooltip');
const svgContainer = document.querySelector('.pyramid-svg-container');
const svgWidth = 320;
const svgHeight = 400;
const pyramidBaseWidth = 260;
const pyramidTopWidth = 40;
const pyramidHeight = 340;
const pyramidX = (svgWidth - pyramidBaseWidth) / 2;
const pyramidY = 40;

function drawPyramid() {
  svg.innerHTML = '';
  let total = pyramidData.reduce((sum, d) => sum + d.value, 0);
  let y = pyramidY;
  let prevWidth = pyramidTopWidth;
  let prevX = (svgWidth - pyramidTopWidth) / 2;
  let topLeft = null;
  let topRight = null;
  let bottomLeft = null;
  let bottomRight = null;
  for (let i = 0; i < pyramidData.length; i++) {
    let d = pyramidData[i];
    let h = d.value / total * pyramidHeight;
    let nextWidth = pyramidTopWidth + (pyramidBaseWidth - pyramidTopWidth) * ((y + h - pyramidY) / pyramidHeight);
    let nextX = (svgWidth - nextWidth) / 2;
    let path = `M${prevX},${y} L${prevX + prevWidth},${y} L${nextX + nextWidth},${y + h} L${nextX},${y + h} Z`;
    let layer = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    layer.setAttribute('d', path);
    layer.setAttribute('fill', d.color);
    layer.setAttribute('stroke', '#fff');
    layer.setAttribute('stroke-width', 2);
    layer.dataset.index = i;
    svg.appendChild(layer);
    layer.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)}%`;
    });
    layer.addEventListener('mouseleave', () => {
      tooltip.style.opacity = '0';
    });
    if (i === 0) {
      topLeft = [prevX, y];
      topRight = [prevX + prevWidth, y];
    }
    if (i === pyramidData.length - 1) {
      bottomLeft = [nextX, y + h];
      bottomRight = [nextX + nextWidth, y + h];
    }
    y += h;
    prevWidth = nextWidth;
    prevX = nextX;
  }
  if (topLeft && topRight && bottomLeft && bottomRight) {
    let outline = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
    outline.setAttribute('points', `
      ${topLeft[0]},${topLeft[1]} 
      ${topRight[0]},${topRight[1]} 
      ${bottomRight[0]},${bottomRight[1]} 
      ${bottomLeft[0]},${bottomLeft[1]}
    `);
    outline.setAttribute('fill', 'none');
    outline.setAttribute('stroke', '#333');
    outline.setAttribute('stroke-width', 3);
    svg.appendChild(outline);
  }
}

function createControls() {
  slidersContainer.innerHTML = '';
  legendContainer.innerHTML = '';
  for (let i = 0; i < pyramidData.length; i++) {
    let d = pyramidData[i];
    let group = document.createElement('div');
    group.className = 'control-group';
    let label = document.createElement('label');
    label.setAttribute('for', `layer${i}-slider`);
    let valueSpan = document.createElement('span');
    valueSpan.id = `layer${i}-value`;
    valueSpan.textContent = `${d.value}%`;
    label.textContent = d.name;
    label.appendChild(valueSpan);
    let slider = document.createElement('input');
    slider.type = 'range';
    slider.id = `layer${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 = pyramidData[idx].value;
  let diff = newValue - oldValue;
  pyramidData[idx].value = newValue;
  let others = pyramidData.map((d, i) => i).filter(i => i !== idx);
  let totalOthers = others.reduce((sum, i) => sum + pyramidData[i].value, 0);
  if (totalOthers > 0) {
    for (let i of others) {
      let prop = pyramidData[i].value / totalOthers;
      pyramidData[i].value -= diff * prop;
    }
  }
  normalizeData();
  updateControls();
  drawPyramid();
}

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

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

normalizeData();
drawPyramid();
createControls();
updateControls();

4. 关键参数说明

  • pyramidData:存储金字塔各层的数据,包括名称、比例和颜色。
  • svgWidthsvgHeight:SVG画布的宽度和高度。
  • pyramidBaseWidthpyramidTopWidth:金字塔底部和顶部的宽度。
  • pyramidHeight:金字塔的高度。
  • pyramidXpyramidY:金字塔的起始X和Y坐标。
  • drawPyramid():绘制金字塔的函数,包括各层和轮廓线。
  • 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;
    }
    .pyramid-svg-container {
      position: relative;
      width: 320px;
      height: 400px;
    }
    #pyramid-svg {
      width: 320px;
      height: 400px;
      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="pyramid-svg-container">
      <svg id="pyramid-svg" viewBox="0 0 320 400"></svg>
      <div id="tooltip"></div>
    </div>
    <div class="controls">
      <h2>调整各层比例</h2>
      <div id="sliders"></div>
      <ul id="legend"></ul>
    </div>
  </div>
  <script>
    let pyramidData = [
      { name: '第1层', value: 40, color: '#f1c40f' },
      { name: '第2层', value: 30, color: '#e67e22' },
      { name: '第3层', value: 20, color: '#3498db' },
      { name: '第4层', value: 10, color: '#9b59b6' }
    ];

    const svg = document.getElementById('pyramid-svg');
    const slidersContainer = document.getElementById('sliders');
    const legendContainer = document.getElementById('legend');
    const tooltip = document.getElementById('tooltip');
    const svgContainer = document.querySelector('.pyramid-svg-container');
    const svgWidth = 320;
    const svgHeight = 400;
    const pyramidBaseWidth = 260;
    const pyramidTopWidth = 40;
    const pyramidHeight = 340;
    const pyramidX = (svgWidth - pyramidBaseWidth) / 2;
    const pyramidY = 40;

    function drawPyramid() {
      svg.innerHTML = '';
      let total = pyramidData.reduce((sum, d) => sum + d.value, 0);
      let y = pyramidY;
      let prevWidth = pyramidTopWidth;
      let prevX = (svgWidth - pyramidTopWidth) / 2;
      let topLeft = null;
      let topRight = null;
      let bottomLeft = null;
      let bottomRight = null;
      for (let i = 0; i < pyramidData.length; i++) {
        let d = pyramidData[i];
        let h = d.value / total * pyramidHeight;
        let nextWidth = pyramidTopWidth + (pyramidBaseWidth - pyramidTopWidth) * ((y + h - pyramidY) / pyramidHeight);
        let nextX = (svgWidth - nextWidth) / 2;
        let path = `M${prevX},${y} L${prevX + prevWidth},${y} L${nextX + nextWidth},${y + h} L${nextX},${y + h} Z`;
        let layer = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        layer.setAttribute('d', path);
        layer.setAttribute('fill', d.color);
        layer.setAttribute('stroke', '#fff');
        layer.setAttribute('stroke-width', 2);
        layer.dataset.index = i;
        svg.appendChild(layer);
        layer.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)}%`;
        });
        layer.addEventListener('mouseleave', () => {
          tooltip.style.opacity = '0';
        });
        if (i === 0) {
          topLeft = [prevX, y];
          topRight = [prevX + prevWidth, y];
        }
        if (i === pyramidData.length - 1) {
          bottomLeft = [nextX, y + h];
          bottomRight = [nextX + nextWidth, y + h];
        }
        y += h;
        prevWidth = nextWidth;
        prevX = nextX;
      }
      if (topLeft && topRight && bottomLeft && bottomRight) {
        let outline = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
        outline.setAttribute('points', `
          ${topLeft[0]},${topLeft[1]} 
          ${topRight[0]},${topRight[1]} 
          ${bottomRight[0]},${bottomRight[1]} 
          ${bottomLeft[0]},${bottomLeft[1]}
        `);
        outline.setAttribute('fill', 'none');
        outline.setAttribute('stroke', '#333');
        outline.setAttribute('stroke-width', 3);
        svg.appendChild(outline);
      }
    }

    function createControls() {
      slidersContainer.innerHTML = '';
      legendContainer.innerHTML = '';
      for (let i = 0; i < pyramidData.length; i++) {
        let d = pyramidData[i];
        let group = document.createElement('div');
        group.className = 'control-group';
        let label = document.createElement('label');
        label.setAttribute('for', `layer${i}-slider`);
        let valueSpan = document.createElement('span');
        valueSpan.id = `layer${i}-value`;
        valueSpan.textContent = `${d.value}%`;
        label.textContent = d.name;
        label.appendChild(valueSpan);
        let slider = document.createElement('input');
        slider.type = 'range';
        slider.id = `layer${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 = pyramidData[idx].value;
      let diff = newValue - oldValue;
      pyramidData[idx].value = newValue;
      let others = pyramidData.map((d, i) => i).filter(i => i !== idx);
      let totalOthers = others.reduce((sum, i) => sum + pyramidData[i].value, 0);
      if (totalOthers > 0) {
        for (let i of others) {
          let prop = pyramidData[i].value / totalOthers;
          pyramidData[i].value -= diff * prop;
        }
      }
      normalizeData();
      updateControls();
      drawPyramid();
    }

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

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

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

四、总结

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

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

封面1.png