给echart饼图添加光圈
UI最近画了个饼图,简简单单渐变色+内外光圈。以为是手拿把掐,后面发现有两个限制,首先是饼图不是位于中心center: ['50%', '50%'],而是偏左,另外页面也是自适应。查了echart的官网文档,没有直接可配置化的东西。玩了半天,后面尝试了使用父节点+蒙版的思路做了div嵌套,感觉太low。顺着这个思路下去,我又改进了,特此分享一下。
效果图
首先看UI给的效果图,第一眼真心简单。
还原稿图
直接上源码。
<template>
<div id="subItemPie" style="width: 100%; height: 300px"></div>
</template>
<script setup>
import * as echarts from 'echarts'
// 饼图光圈
import { graphicPieCircle } from '@/echarts/conponents'
// 渐变色
import { getLinearGradientColorList } from '@/echarts/color'
// echart相关:数据、配置、颜色
const echart = reactive({
data: [],
list: [
{ name: '分项1', key: 'item1' },
{ name: '分项2', key: 'item2' },
{ name: '分项3', key: 'item3' },
{ name: '分项4', key: 'item4' },
{ name: '分项5', key: 'item5' }
],
colorList: []
})
const onSearch = () => {
// TODO: 获取数据
echart.data = { item1: 487, item2: 343, item3: 22, item4: 642, item5: 465 }
// 等待数据后渲染echart
initChart()
}
onMounted(() => {
onSearch()
})
// 获取渐变色(支持传值,防止UI不按照套路来背刺)
echart.colorList = getLinearGradientColorList()
// 为了不增加没必要的心智负担,去掉了多余的代码
const initChart = () => {
chart = echarts.init(document.querySelector('#subItemPie'))
const options = {
color: echart.colorList,
legend: {
orient: 'vertical',
top: 'center',
data: echart.list.map(item => item.name)
},
// 这里需要传递echart的ID和饼图的center
graphic: graphicPieCircle('subItemPie', ['30%', '50%']),
series: [
{
type: 'pie',
radius: ['40%', '80%'],
center: ['30%', '50%'],
data: echart.list.map(item => {
return {
name: item.name,
value: echart.data[item.key]
}
})
}
]
}
renderChart(options)
}
</script>
这里主体代码就很清爽。接下来是封装的两个关键性的方法。
渐变色
渐变色比较简单,官方文档写得很清楚,主要是不好使用,每次都要写很多代码,所以做了封装。
// src/echarts/color.js
// 默认渐变色色组
export const defaultColorList = [
['#12A3E8', '#63E3FF'],
['#5AD8A6', '#93EED2'],
['#F2D647', '#F9F4C2'],
['#FA9D14', '#FCDF83'],
['#FC8A72', '#FC7131']
]
// 获取渐变色list
export const getLinearGradientColorList = (colorList = defaultColorList) => {
const linearGradientcolorList = []
colorList.forEach((item, index) => {
let color = item
if (Array.isArray(item)) {
color = getLinearGradientcolor(item[0], item[1])
}
linearGradientcolorList.push(color)
})
return linearGradientcolorList
}
// 获取单个渐变色(这里可以继续改造,通过传值来改变渐变方向)
export const getLinearGradientcolor = (startColor, endColor) => {
return {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: startColor
},
{
offset: 1,
color: endColor
}
]
}
}
饼图光圈
这里的难点主要是要跟随饼图,原本是加了一个父节点,然后一个蒙版做相对定位,整个代码就不简洁了。蒙版放上面会有事件遮挡问题;蒙版放下面,UI万一要给echart加个背景色就GG了。以上问题其实都还好,要么是通过增加代码解决,要么通过武力解决。但总归太麻烦,而且一点也不简洁。
// src/echarts/components/pieCircle/index.js
// 因为放了很多组件套件,所以细分了文件夹,最后所有组件套件都通过src/echarts/components/index.js导出来
import InsiderCircle from './insider_circle.png'
import OutsiderCircle from './outsider_circle.png'
/**
* domId: String echart渲染的节点ID
* center: 饼图的中心属性
*/
export default (domId, center = ['50%', '50%']) => {
const dom = document.querySelector('#' + domId)
if (!dom) return
// 通过ID获取echart的宽高,并取最小值,这样就得到了饼图的大小
const domWidth = dom.clientWidth
const domHeight = dom.clientHeight
const size = Math.min(domWidth, domHeight)
// 根据饼图的中心数据,获取到饼图的定位值(相对echart画布的x,y坐标)
// 这里先判断center里的数值是否为绝对值还是代码百分比的字符串,好方便计算
const left = Number.isFinite(domWidth) && typeof center[0] === 'string' ? (domWidth * parseFloat(center[0])) / 100 - size / 2 : center[0] - size / 2
const top = Number.isFinite(domHeight) && typeof center[1] === 'string' ? (domHeight * parseFloat(center[1])) / 100 - size / 2 : center[1] - size / 2
return {
elements: [
{
type: 'group',
left: left,
top, // 我虽然不说,但是大家应该都会知道这行代码是top:top的意思吧,同名简写而已
children: [
{
// 内圈
type: 'image',
style: {
// 因为UI内外圈是图片,防止变形,根据图片的大小设置图片的宽高
image: InsiderCircle,
width: 109,
height: 109
},
left: 'center',
top: 'center',
},
{
// 外圈
type: 'image',
style: {
image: OutsiderCircle,
width: 283,
height: 283
},
left: 'center',
top: 'center'
},
{
// 补位块,主要是要把group撑起来,保持跟饼图大小一致,好定位。
// 为什么不在group里设置宽高,官方文档都说清楚,group的宽高只能用来定位
type: 'rect', // 随便选的一个图形,这个简单,你可以随意自己改其他图形,只要保持跟饼图宽高一致就行
shape: {
width: size,
height: size
},
style: {
// 透明,防止UI背刺
fill: 'transparent'
},
left: 'center',
top: 'center'
}
]
}
]
}
}
总结:这里主要是利用了echart的原生graphic属性。然后依次解决了以下问题:
- 获取饼图环的真实宽高(画布宽高的最小值)
- 根据饼图环的真实宽高获取其左上角的真实定位
- 通过
graphic画出背景组,赋予其与饼图左上角相同的定位 - 背景组内画出内圈和外圈,以及最重要的补位块,赋予背景组与饼图同样大宽高,到这里其实已经基本算完成了。此刻背景组和饼图是完全重合的。
- 然后通过
left: 'center'和top: 'center'将内圈和外圈在背景组内居中,大功告成
动起来
到这里就完了吗?怎么可能,UI背刺了这么久,不能说就这点内容我花了半个下午的时间。得加点东西,才能证明我在努力工作,所以我顺便让这个内圈还外圈动起来。还原度直接拉爆,来到120%的还原。这没人说我摸鱼了吧。
最终效果,内圈逆时针,外圈顺时针。
// src/echarts/components/pieCircle/index.js
// 熟悉上部分的直接跳到内圈:33-53行代码注解即可。
import InsiderCircle from './insider_circle.png'
import OutsiderCircle from './outsider_circle.png'
export default (domId, center = ['50%', '50%']) => {
const dom = document.querySelector('#' + domId)
if (!dom) return
const domWidth = dom.clientWidth
const domHeight = dom.clientHeight
const size = Math.min(domWidth, domHeight)
const left = Number.isFinite(domWidth) && typeof center[0] === 'string' ? (domWidth * parseFloat(center[0])) / 100 - size / 2 : center[0] - size / 2
const top = Number.isFinite(domHeight) && typeof center[1] === 'string' ? (domHeight * parseFloat(center[1])) / 100 - size / 2 : center[1] - size / 2
return {
elements: [
{
type: 'group',
left,
top,
children: [
{
// 内圈
type: 'image',
style: {
image: InsiderCircle,
width: 109,
height: 109
},
left: 'center',
top: 'center',
// 内圈逆时针旋转
originX: 109 / 2, // 定位旋转中心,这里是相对本元素,即宽高109的中心,不要写成size/2
originY: 109 / 2,
keyframeAnimation: [ // 动画开始
{
duration: 3000, // 一轮动画所需时间,数值越大越慢,越小越快
loop: true, // 是否循环
keyframes: [ // 动画内容,这里需要参考官方文档的属性,内容还是比较少的,就x, y, style, shape 等
{
percent: 0, // 内容其实跟css的动画很类似了,起点
rotation: 0 // 起始旋转0度
},
{
percent: 0.5, // 中间点,这里可以多写几个,比如0.2,0.9等等,可以有更加精细的效果
rotation: Math.PI // 一个Math.PI代表180° 半圆
},
{
percent: 1, // 结束点
rotation: Math.PI * 2 // 整圈
}
]
}
]
},
{
// 外圈
type: 'image',
style: {
image: OutsiderCircle,
width: 283,
height: 283
},
left: 'center',
top: 'center',
originX: 283 / 2,
originY: 283 / 2,
keyframeAnimation: [
{
duration: 3000,
loop: true,
keyframes: [
{
percent: 0,
rotation: 0
},
{
percent: 0.5,
rotation: -Math.PI
},
{
percent: 1,
rotation: -Math.PI * 2
}
]
}
]
},
{
// 补位块
type: 'rect',
shape: {
width: 300,
height: 300
},
style: {
fill: 'transparent'
},
left: 'center',
top: 'center'
}
]
}
]
}
}