如何实现扇形,环形进度条和环形图
本文主要提供了:
- svg实现可交互环形图的流程(只提供整体流程,没有全部代码),如果你的项目需要环形图,你又不想引入图表类插件,建议可以手写一下。
- 纯css实现的静态/动态 的
扇形
/环形进度条
,顺手用相同的方式制作了一个特别没用的环形图。。。真的是特别没用。顺便一提,环形进度条是 头条前端社招 的一道面试题。。。
svg绘制环形图和一些需要注意的点
来看看一个简易的环形图demo!不知道怎么把鼠标也录进去,提醒的浮窗在鼠标右上角6px的位置。。。大致就是这样吧,蛮好玩的
主要也就这么些东西
- 对应的DOM结构
- svg扇形绘制的方法
- 扇形绘制时候需要注意的点
- 数据展示的引导线和文案的绘制
- 数据引导线的防重叠避让
- 文案的右对齐
- 鼠标悬浮数据区域后的额外绘制和悬浮提示
第一步:对应dom结构
用的vue+jsx,大致内容如下,循环体没写,所有内容的id随循环体的index变化即可,这里是单个环上数据块的代码,path用于画扇形,polyline用于画数据引导线,text是描述文本,分为两块,一个用来写数据量,一个用于描述数据内容。最后的circle用于把扇形遮挡成环形
<svg width="300px" height="223px">
<path
id="ring1"
onMouseenter={this.larger.bind(this, xxx)}
onMouseleave={this.smaller.bind(this, xxx)}
onClick={this.sendMessage.bind(this, xx)}
fill="#df3849"
/>
<polyline
id="line1"
onMouseenter={this.larger.bind(this, xxx)}
onMouseleave={this.smaller.bind(this, xxx)}
style="fill:transparent;stroke:#df3849;stroke-width:1"
/>
{chapterContent.task.un_submit && (<text
id="text1"
onMouseenter={this.larger.bind(this, xxx)}
onMouseleave={this.smaller.bind(this, xxx)}
onClick={this.sendMessage.bind(this, xx)}
>
<tspan class="svg-num" fill="#df3849" id="text11">
{chapterContent.task.un_submit}
</tspan>
<tspan class="svg-word" id="text12">
未提交
</tspan>
</text>
<circle cx="150" cy="115" r="37" fill="#FFF" />
</svg>
)}
第二步:svg扇形的绘制
绘制扇形的核心就是定义<path>的路径,代码如下
let path = document.getElementById(pathId)
/**
数组内参数对应的内容是,
0~2的M代表moveTo,后面跟起点坐标;
3~5的L代表lineto,后跟某个坐标,就是划从起点到左边点的线;
6~11的A代表elliptical Arc,是画椭圆弧操作,后跟两个半径,对应椭圆的xr和yr;
然后是x-axis-rotation,对应X轴的旋转角度
接着是large-arc-flag,对应你想要拿这两个点画一个钝角弧还是锐角弧
最后是sweep-flag,顺逆时针画弧,1代表从起点到终点弧线绕中心顺时针方向,0代表逆时针方向,在这里我们使用顺时针就是画一个标准的扇形,逆时针。。。大致会画出一个缺口的圆
12~13是弧的终点坐标
14的Z是closepath代表闭合路径,会把弧的重点和起点连起来形成一个图形
**/
let descriptions = ["M", 0, 0, "L", x, y, "A", r, r, 0, 1, 1, x1, y1, "Z"]
path.setAttribute("d", descriptions.join(" "))
展示下我自己写的垃圾函数,大致逻辑就是函数的参数为pathId,lineId,textId,或者这里可以换成一个统一的id,然后用字符串操作拼接,我这是在函数外面就拼接了。。。degree是当前数据块应该占用的角度,percentage是当前数据块所占比例,r是扇形的半径,offsetR是图像在svg上的偏移量,还会根据全局的offsetX和offsetY具体调整,x和y是上个数据块画完的终点坐标,用于当前数据块的起点,当前数据块画完后也会return出去一对xy,用于下个环形的绘制。
drawSector(id, lineId, textId, degree, percentage, r, offsetR, x, y) {
// 如果没有占比,返回当前绘画的起点
if (percentage == 0) {
return {
x: x,
y: y
}
}
let path = document.getElementById(id)
path.setAttribute(
"transform",
"translate(" +
(offsetR + this.offsetX) +
"," +
(offsetR + this.offsetY) +
")"
)
// 如果占比100%。走额外的圆形绘画逻辑,结束后续的扇形绘画逻辑
// 这里有个问题就是起点终点重合了似乎画不出圆,我想的解决方案就是在同一个path内画半圆然后再画一个半圆
if (percentage == 1) {
let descriptions = [
"M", 0, 0, "L", x, y, "A", r, r, 0, 1, 1, x, -y, "L", x, -y, "A", r, r, 0, 1, 1, x, y, "Z"
]
// 并在90度的位置划线
this.drawLines(lineId, textId, r, offsetR, 270 - (percentage * 360) / 2)
path.setAttribute("d", descriptions.join(" "))
return false
}
// 正常的扇形 计算第一个点的终点坐标
let lenghty = window.Number(percentage * 360 > 180)
let PIdegree = (degree / 180) * Math.PI
// calculatePositions是一个计算坐标的函数,其实就是Math.sin和Math.cos然后返回xy坐标
let { x1, y1 } = this.calculatePositions(r, PIdegree)
// 画引导线的函数
this.drawLines(lineId, textId, r, offsetR, degree - (percentage * 360) / 2)
// y往下是正,x往右是正
let descriptions = ["M", 0, 0, "L", x, y, "A", r, r, 0, lenghty, 1, x1, y1, "Z"]
path.setAttribute("d", descriptions.join(" "))
return {
x: x1,
y: y1
}
}
扇形绘制时候需要注意的点
1.占比为0,把自己的起点直接return出去给下一个环用
2.占比100%,似乎是需要画2个半圆,用这种方法起点终点相同画不出来东西
3.计算环形占用的角度,并把角度中点坐标传递给划线函数,让引导线从环形的中间往外指
第三步: 数据展示引导线的绘制
大致思路就是从环形数据块的中点引出去一条线
drawLines(lineId, textId, r, offsetR, halfDegree) {
let path = document.getElementById(lineId)
path.setAttribute(
"transform",
"translate(" +
(offsetR + this.offsetX) +
"," +
(offsetR + this.offsetY) +
")"
)
let descriptions
// 这里会做一些特殊数据的特殊待遇。。。比如我这里有一个稳定出现在第一个数据块内部的子数据块我把他的引导线写在了右上角。。。
if (lineId == "line5") {
let temp = halfDegree > 15 ? 15 : halfDegree
let { x1, y1 } = this.calculatePositions(r, (temp / 180) * Math.PI)
descriptions = [
`${x1},${y1}`,
`${x1 + 6},${y1 - 55}`,
`${x1 + 110},${y1 - 55}`
]
path.setAttribute("points", descriptions.join(" "))
this.drawText(textId, x1 + 50, y1 - 55, offsetR)
return false
}
let { x1, y1 } = this.calculatePositions(r, (halfDegree / 180) * Math.PI)
// 此处省略1w字的引导线避让规则,封装的很差,没脸展示
// ...
// 第1,2,3,4象限的普通引导线绘制规则
if (halfDegree < 90) {
descriptions = [
`${x1},${y1}`,
`${x1 + 10},${y1 - 6}`,
`${x1 + 65},${y1 - 6}`
]
path.setAttribute("points", descriptions.join(" "))
this.drawText(textId, x1 + 30, y1 - 6, offsetR)
this.beforeYPosition = y1 - 6
} else if (halfDegree < 180) {
descriptions = [
`${x1},${y1}`,
`${x1 + 10},${y1 + 6}`,
`${x1 + 65},${y1 + 6}`
]
path.setAttribute("points", descriptions.join(" "))
this.drawText(textId, x1 + 30, y1 + 6, offsetR)
this.beforeYPosition = y1 + 6
} else if (halfDegree < 270) {
descriptions = [
`${x1},${y1}`,
`${x1 - 10},${y1 + 6}`,
`${x1 - 65},${y1 + 6}`
]
path.setAttribute("points", descriptions.join(" "))
this.drawText(textId, x1 - 65, y1 + 6, offsetR)
this.beforeYPosition = y1 + 6
} else {
descriptions = [
`${x1},${y1}`,
`${x1 - 10},${y1 - 6}`,
`${x1 - 65},${y1 - 6}`
]
path.setAttribute("points", descriptions.join(" "))
this.drawText(textId, x1 - 65, y1 - 6, offsetR)
this.beforeYPosition = y1 - 6
}
}
引导线绘制时候需要注意的点
1.从环形中点开始画引导线,第1象限往右上偏移,第2象限往右下偏移,第3象限往左上偏移,第4象限往左上偏移,
2.引导线和文本如何避免重叠,这里我省略掉了我不太完善的防重叠代码,大致思路就是,每次画完一个引导线,用this.beforeYPosition记录y轴坐标位置,再记录一个象限状态或者角度值,下次绘制的时候发现y轴坐标相差小于某一阈值(比如我这里是35)并且在同一象限,我们就会让线绕的远一点,每个象限会有自己独特的绕远过程,比如下面这段代码
// 第三象限避让示例
else if (halfDegree < 270) {
descriptions = [
`${x1},${y1}`, // 正常的起点
`${(x1 - 68) / 2},${y1}`, // 先向左走一截,是为了线不会和环形重叠
`-68,${y1 - 35}`, // 再向上35进行避让
`-118,${y1 - 35}` // 往外延伸用于展示数据的部分
]
path.setAttribute("points", descriptions.join(" "))
this.drawText(textId, -118, y1 - 35, offsetR)
this.beforeYPosition = y1 - 35
}
第四步: 数据展示文案的绘制
在线的上方写数字,在下面写文字,因为文案一般都是固定的,所以位置很好定,但是数字是后端返回的,长度会变,所以右对齐的时候需要点特殊手段,大致代码:
drawText(textId, x, y, offsetR) {
let path = document.getElementById(textId)
path.setAttribute(
"transform",
"translate(" +
(offsetR + this.offsetX) +
"," +
(offsetR + this.offsetY) +
")"
)
let path1 = document.getElementById(textId + "2")
this.drawNum(textId, x, y, offsetR)
this.drawPureText(path1, x, y, offsetR)
},
drawPureText(path, x, y) {
path.setAttribute("x", x)
path.setAttribute("y", y + 12)
},
drawNum(textId, x, y) {
let number
let {
un_submit,
wait,
revising,
done,
wanke_unsubmit
} = this.chapterContent.task
switch (textId) {
case "text1":
number = un_submit
break
case "text2":
number = wait
break
case "text3":
number = revising
break
case "text4":
number = done
break
case "text5":
number = wanke_unsubmit
x += 25
break
default:
break
}
if (x > 0) {
// 第1、2象限右对齐
if (number >= 100) {
x += 8
} else if (number >= 10) {
x += 18
} else {
x += 28
}
}
let path = document.getElementById(textId + "1")
path.setAttribute("x", x)
path.setAttribute("y", y - 4)
},
文本绘制时候需要注意的点似乎也就只有1,2象限的右对齐问题
最后一步:鼠标悬浮数据区域后的额外绘制和悬浮提示
我这里是监听MouseEenter和MouseLeave然后对相关的部分进行重新绘制,代码如下:
larger(index) {
this.showFloatRemind = 1
let { total, un_submit, wait, wanke_unsubmit } = this.chapterContent.task
if (index == 1) {
window.cancelAnimationFrame(this.firstAnimation)
let animation = () => {
this.onlyDrawSector(
"ring" + index,
(un_submit / total) * 360,
un_submit / total,
this.first_range,
65,
0,
-this.first_range
)
this.first_range += 0.5
if (this.first_range < 70) {
this.firstAnimation = window.requestAnimationFrame(animation)
}
}
animation()
}
// 省略其他index
},
smaller(index) {
this.showFloatRemind = 0
let { total, un_submit, wait, wanke_unsubmit } = this.chapterContent.task
if (index == 1) {
window.cancelAnimationFrame(this.firstAnimation)
let animation = () => {
this.onlyDrawSector(
"ring" + index,
(un_submit / total) * 360,
un_submit / total,
this.first_range,
65,
0,
-this.first_range
)
this.first_range -= 0.5
if (this.first_range > 65) {
this.firstAnimation = window.requestAnimationFrame(animation)
}
}
animation()
}
}
需要注意的点就是利用requestAnimationFrame,和在切换状态时记得清除掉无用的requestAnimationFrame状态,防止出现bug
扇形
今年3月份还是5月份看了个挺有趣的技术分享,讲的是css变量的应用,里面主要讲的是如何用css变量+opacity+rotate实现一个扇形,具体方式如下
<!DOCTYPE html>
<html>
<head>
<style>
.pie-simple {
width: 128px;
height: 128px;
background-color: white;
border-radius: 50%;
overflow: hidden;
}
.pie-left,
.pie-right {
width: 50%;
height: 100%;
float: left;
position: relative;
overflow: hidden;
}
.pie-left::before,
.pie-right::before,
.pie-right::after {
content: '';
position: absolute;
width: 100%; height: 100%;
background-color: red;
}
.pie-left::before {
left: 100%;
transform-origin: left;
transform: rotate(calc(3.6deg * (var(--percent) - 50)));
opacity: calc(99999 * (var(--percent) - 50));
}
.pie-right::before {
right: 100%;
transform-origin: right;
transform: rotate(calc(3.6deg * var(--percent)));
}
.pie-right::after {
opacity: calc(99999 * (var(--percent) - 50));
}
</style>
</head>
<body>
<div class="pie-item">
<p>10%大小</p>
<div class="pie-simple" style="--percent: 10;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
</div>
<div class="pie-item">
<p>40%大小</p>
<div class="pie-simple" style="--percent: 40;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
</div>
<div class="pie-item">
<p>80%大小</p>
<div class="pie-simple" style="--percent: 80;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
</div>
<div class="pie-item">
<p>99%大小</p>
<div class="pie-simple" style="--percent: 99;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
</div>
</body>
</html>
这只是一个静态的扇形展示,核心思想是利用css变量控制opacity,opacity为负数被认为是0,大于1被认为是1。
效果图大致就是这样的:
image.png
总结一下发生了啥:
- 正方形被一分为二,左边一个白色矩形div,右边一个白色矩形div,超出正方形的部分overflow掉
- 左边div的:before 有背景色有透明度且转动 ,负责控制扇形超过50%时,左侧部分的显示
- 右边div的:after 有背景色有透明度不转动,负责控制扇形超过50%后,右侧部分全部显示
- 右边div的:before 有背景色不透明且转动 ,负责控制扇形小于50%时,右侧部分的显示
关于扇形的动画放在环形进度条里一起再说
环形进度条
环形进度条最直观的实现方式其实就是一个大的扇形扣上个一个小圆,代码和上面的差不多,不过扇形/饼状图一般可以是静态的,但是进度条普遍都是是动态的,如果让css变量实现的环形进度条动起来?主要介绍下面两个方案:
1. 保留css变量,使用animation来实现动画
css变量并不支持平滑的过度,但是你可以在固定帧规定变量的值,你可以写的足够细,动画就足够顺化,但是平时也会经常看到那种duangduangduang~一截一截往前蹦的进度条。
.pie-simple {
animation: round 10s linear infinite;
}
@keyframes round{
0%{--percent: 0;}
10%{--percent: 10;}
20%{--percent: 20;}
30%{--percent: 30;}
40%{--percent: 40;}
50%{--percent: 50;}
60%{--percent: 60;}
70%{--percent: 70;}
80%{--percent: 80;}
90%{--percent: 90;}
100%{--percent: 100;}
}
顺带一说只有Chrome能像上面那么写,火狐需要把变量和使用到变量的属性都塞到keyframe里,ie和safari干脆就直接不支持了。。。
我在DOM.style里找不到css变量对应的属性,所以似乎
没法直接通过requestAnimationFrame控制--percent来实现动画,但是可以在CSSRuler里用js手撕关键帧。下图是示例
2. 移除css变量,使用animation来实现动画
因为兼容性和帧的复杂性问题,实现动画还是放弃css变量比较好,最直观的影响就是不能让opacity实现有效的突变,把opacity放在动画里会有渐变的过程,所以这里又出现了两种解决方案:
a.给左侧矩形div增加一个伪类,用于没有超过50%左侧的白色遮盖,利用animation的steps()实现opacity的突变,代码如下
<!DOCTYPE html>
<html>
<head>
<style>
.pie-simple {
position: relative;
width: 128px;
height: 128px;
background-color: white;
border-radius: 50%;
overflow: hidden;
}
.pie-left,
.pie-right {
width: 50%;
height: 100%;
float: left;
position: relative;
overflow: hidden;
}
.pie-left::before,
.pie-right::before,
.pie-left::after,
.pie-right::after{
content: '';
position: absolute;
width: 100%; height: 100%;
background-color: red;
}
.pie-left::after{
background: #fff
}
.pie-left::before {
left: 100%;
transform-origin: left;
animation: fuspin 10s linear infinite;
}
.pie-left::after {
animation: second-half-hide 10s steps(1, end) infinite;
}
.pie-right::before {
right: 100%;
transform-origin: right;
animation: spin 10s linear infinite;
}
.pie-right::after {
animation: second-half-show 10s steps(1, end) infinite;
}
@keyframes spin {
0% { transform: rotate(0); }
100% { transform: rotate(360deg); }
}
@keyframes fuspin {
0% { transform: rotate(180deg); }
100% { transform: rotate(540deg); }
}
@keyframes second-half-hide {
0% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes second-half-show {
0% { opacity: 0; }
50%, 100% { opacity: 1; }
}
.cover{
width: 120px;
height: 120px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
margin: auto;
}
</style>
</head>
<body>
<div class="pie-item">
<div class="pie-simple">
<div class="pie-left"></div>
<div class="pie-right"></div>
<div class="cover"></div>
</div>
</div>
</body>
</html>
效果图如下,gif显得有点卡,实际是很流畅的,在mac的chrome下左侧会有个缝隙,白色的:after并不能完全盖住红色的:before。。。在window的chrome下就没有
b.只使用2个带背景色的伪类,利用超出overflow+两者的动画错开实现空白占位和有色占位代码如下
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.pie-spin {
position: relative;
width: 128px; height: 128px;
background-color: white;
border-radius: 50%;
overflow: hidden;
}
.pie-spin-left,
.pie-spin-right {
width: 50%; height: 100%;
float: left;
position: relative;
overflow: hidden;
}
.pie-spin-left::before,
.pie-spin-right::before {
content: '';
position: absolute;
width: 100%; height: 100%;
background-color: red;
}
.pie-spin-left::before {
left: 100%;
transform-origin: left;
animation: spinWait 3.2s infinite linear;
}
.pie-spin-right::before {
right: 100%;
transform-origin: right;
animation: spinWait 3.2s infinite linear;
}
@keyframes spinWait {
0% { transform: rotate(0deg); }
50%, 100% { transform: rotate(180deg); }
}
@keyframes spinWait {
0%, 50% { transform: rotate(0deg); }
100% { transform: rotate(180deg); }
}
.cover{
width: 120px;
height: 120px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
margin: auto;
}
</style>
</head>
<body>
<div class="pie-spin">
<div class="pie-spin-left"></div>
<div class="pie-spin-right"></div>
<div class="cover"></div>
</div>
</body>
</html>
效果图如下,因为只有两个块,没有遮罩层,不显示的时候躲在对边等待转动,所以也不会出现那一个小小的缝隙
环形图
上面的idea过于舒畅,导致我当初第一反应就是用css变量或者伪类的旋转和遮盖来先出一版,调试了大概一个下午,最后也只能是正常的展示吧,因为一层一层的遮盖导致悬浮点击的交互根本无从入手,最后5个数据块,20个伪类层峦叠嶂弄得我十分难受,如果你只是想要一个不能动的环形图,也是能够实现的,注意后出现的部分的:before和:after的z-index高于前者就行,代码如下
<!DOCTYPE html>
<html>
<head>
<style>
.round-container{
position: relative;
height: 150px;
}
.pie-simple {
position: absolute;
width: 130px;
height: 130px;
top: 10px;
left: 60px;
background-color: white;
border-radius: 50%;
overflow: hidden;
/*box-shadow: 1px 1px 1px 3px rgba(0,0,0,0.1)*/
}
.pie-left,
.pie-right {
position: absolute;
width: 50%;
height: 100%;
float: left;
overflow: hidden;
}
.pie-left{
left: 0;
top: 0
}
.pie-right {
left: 50%;
top: 0;
}
.pie-left::before,
.pie-right::before,
.pie-right::after {
content: '';
position: absolute;
width: 100%; height: 100%;
background-color: #df3849;
}
.pie-left::before {
left: 100%;
transform-origin: left;
transform: rotate(calc(3.6deg * (var(--percent) - 50)));
opacity: calc(99999 * (var(--percent) - 50));
}
.pie-right::before {
right: 100%;
transform-origin: right;
transform: rotate(calc(3.6deg * var(--percent)));
}
.pie-right::after {
opacity: calc(99999 * (var(--percent) - 50));
}
.a .pie-left::before{
z-index: 300;
}
.b .pie-left::before{
: 200;
background-color:#6434e5;
}
.c .pie-left::before{
z-index: 100;
background-color:#4d8ef6;
}
.d .pie-left::before{
z-index: 50;
background-color:#69a42f;
}
.a .pie-right::before{
z-index: 300;
}
.b .pie-right::before{
z-index: 200;
background-color:#6434e5;
}
.c .pie-right::before{
z-index: 100;
background-color:#4d8ef6;
}
.d .pie-right::before{
z-index: 50;
background-color:#69a42f;
}
.a .pie-right::after{
z-index: 300;
}
.b .pie-right::after{
z-index: 200;
background-color:#6434e5;
}
.c .pie-right::after{
z-index: 100;
background-color:#4d8ef6;
}
.d .pie-right::after{
z-index: 50;
background-color:#69a42f;
}
.b{
width: 129px;
height: 129px;
top: 10.5px;
left: 60.5px;
}
.c{
width: 128px;
height: 128px;
top: 11px;
left: 61px;
}
.e {
width: 100px;
height: 100px;
top: 25px;
left: 75px;
}
.e .pie-right::after{
z-index: 400;
background-color:#ff8d05;
}
.e .pie-right::before{
z-index: 400;
background-color:#ff8d05;
}
.e .pie-left::before{
z-index: 400;
background-color:#ff8d05;
}
.cover{
position: absolute;
top: 40px;
left: 90px;
width: 70px;
height: 70px;
z-index: 9999;
background-color: white;
border-radius: 50%;
overflow: hidden;
}
</style>
</head>
<body>
<div class="round-container">
<div class="pie-simple a" style="--percent: 20;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
<div class="pie-simple b" style="--percent: 60;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
<div class="pie-simple c" style="--percent: 80;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
<div class="pie-simple d" style="--percent: 100;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
<div class="pie-simple e" style="--percent: 10;">
<div class="pie-left"></div>
<div class="pie-right"></div>
</div>
<div class="cover"></div>
</div>
</body>
<script type="text/javascript">
</script>
</html>
静态的效果如下:
因为这个是一层一层盖住的,你想让一个环放大,之前被挡住的地方就漏出来了~所以解决方案是让每一个的环都是从上一个环结束的位置开始,但是这样有感觉好麻烦,而且你把鼠标浮上去。。。你根本拿不到你想要的event.target[手动捂脸],所以我只能用svg去画了。。。
最后,感谢看官看到最后~