给echart饼图添加光圈(自适应,定位饼中心)

1,721 阅读6分钟

给echart饼图添加光圈

UI最近画了个饼图,简简单单渐变色+内外光圈。以为是手拿把掐,后面发现有两个限制,首先是饼图不是位于中心center: ['50%', '50%'],而是偏左,另外页面也是自适应。查了echart的官网文档,没有直接可配置化的东西。玩了半天,后面尝试了使用父节点+蒙版的思路做了div嵌套,感觉太low。顺着这个思路下去,我又改进了,特此分享一下。

效果图

首先看UI给的效果图,第一眼真心简单。

image.png

还原稿图

直接上源码。

<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'
          }
        ]
      }
    ]
  }
}

最终效果图

最终效果图