我是设计师邱兴,一个学习前端的设计师,今天给大家制作一个用SVG实现的环形图可视化组件,SVG相较于Echart来说制作简单,但是效果可以非常丰富。
SVG 环形图可视化组件制作教程
一、目标
通过 HTML、CSS 和 JavaScript 创建一个交互式的 SVG 环形图可视化组件,实现以下功能:
- 使用 SVG 绘制环形图,并支持动画效果。
- 通过滑块动态调整各部分比例。
- 显示各部分的图例和提示信息。
二、所需工具与准备
- 工具:
- 一个文本编辑器(如 Notepad++、VS Code 等)。
- 浏览器(用于预览效果)。
- 基础准备:
- 确保你对 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:存储环形图各部分的数据,包括名称、比例和颜色。cx和cy:环形图的中心坐标。rOuter和rInner:环形图的外半径和内半径。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 绘制环形图,通过滑块动态调整各部分比例,并显示各部分的图例和提示信息。你可以通过调整代码中的参数来改变环形图的外观和行为。希望这个教程对你有所帮助!
以上制作的是一个最简单的一个带刻度的仪表盘,我还录制了一个更加美观的带刻度的仪表盘的视频教程,有兴趣的小伙伴可以点击查看。