基于Echarts的饼图

饼图样式

image

饼图核心代码

 <div :class="$style.main" @click="onClick">
    <!-- ECharts容器 -->
    <div ref="chartContainer" :class="$style.chartBox" :style="{
      width: size + 'px', height: size + 'px'
    }"></div>

    <!-- 显示详情列表 -->
    <div :class="$style.detailsBox">
      <div v-for="(item, index) in dataWithPercentage" :key="index" :class="$style.detailItem" @click.stop="onClick">
        <div :class="$style.detailText">{{ item.name }}</div>
        <div :class="$style.detailPercentage" :style="{ color: item.color }">
          {{ item.percentage.toFixed(1) }}%
        </div>
      </div>
    </div>
  </div>
// 注册必要的组件
echarts.use([
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  PieChart,
  SVGRenderer
])

const props = withDefaults(defineProps<{
  pieData?: Array<{
    name: string
    value: number
    color?: string
  }>
  size?: number
}>(), {
  //在defineProps中如果有对象数组类型需要指定默认值只能使用箭头函数返回,不然会报错。
  pieData: () => [
    { name: 'Project 1', value: 21, color: '#0099f2' },
    { name: 'Project 2', value: 65, color: '#f7b501' },
    { name: 'Project 3', value: 14, color: '#44d7b6' }
  ],
  size: 100
})

const emits = defineEmits<{
  click: [data: string]
}>()

const chartContainer = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null

// 计算包含百分比的数据
const dataWithPercentage = computed(() => {
  const total = props.pieData.reduce((sum, item) => sum + item.value, 0)
  return props.pieData.map(item => ({
    ...item,
    percentage: (item.value / total) * 100
  }))
})

// 初始化/更新ECharts
const updateChart = () => {
  if (!chartContainer.value) return

  // 如果图表实例不存在,则初始化
  if (!chartInstance) {
    chartInstance = echarts.init(chartContainer.value, null, {
      renderer: 'svg',
      devicePixelRatio: window.devicePixelRatio
    })
  }

  const option = {
    series: [{
      type: 'pie',
      radius: ['60%', '70%'],
      avoidLabelOverlap: false,
      itemStyle: {
        borderRadius: 3,
        borderColor: '#fff',
        borderWidth: 0
      },
      label: {
        show: false,
        position: 'center',
        fontSize: 24,
        formatter: '{d}%',
        color: "{c}"
      },
      emphasis: {
        scale: true,
        scaleSize: 1,
        itemStyle: {
          shadowBlur: 1,
          shadowColor: 'rgba(0, 0, 0, 0.3)'
        },
        // 注意:这里的 label 是 emphasis 的子属性
        label: {
          show: true,
          lineHeight: 30,
          // 不使用全局 rich,在 data 中为每个项单独配置
          formatter: function (params: any) {
            // 使用数据项索引生成对应的 rich 样式名称
            return `{number${params.dataIndex}|${params.percent.toFixed(1)}}{percentSign|%}`;
          }
        }
      },
      labelLine: {
        show: false
      },
      data: props.pieData.map((item, index) => ({
        value: item.value,
        name: item.name,
        itemStyle: {
          color: item.color || undefined
        },
        // 为每个数据项单独配置强调状态下的标签样式
        // 因为显示模式切到svg后,使用options配置富文本颜色的{c} 失效,所以需要手动为每一项填写颜色
        emphasis: {
          label: {
            rich: {
              [`number${index}`]: {
                fontSize: 24,
                color: item.color, // 使用与扇形相同的颜色
                verticalAlign: 'center',
                fontFamily: 'Alibaba-PuHuiTi, sans-serif'
              },
              percentSign: {
                fontSize: 7,
                color: 'grey'
              }
            }
          }
        }
      }))
    }]
  }

  chartInstance.setOption(option)
  chartInstance.resize() // 确保图表尺寸正确
}

全代码

<template>
  <div :class="$style.main" @click="onClick">
    <!-- ECharts容器 -->
    <div ref="chartContainer" :class="$style.chartBox" :style="{
      width: size + 'px', height: size + 'px'
    }"></div>

    <!-- 显示详情列表 -->
    <div :class="$style.detailsBox">
      <div v-for="(item, index) in dataWithPercentage" :key="index" :class="$style.detailItem" @click.stop="onClick">
        <div :class="$style.detailText">{{ item.name }}</div>
        <div :class="$style.detailPercentage" :style="{ color: item.color }">
          {{ item.percentage.toFixed(1) }}%
        </div>
      </div>
    </div>
  </div>
</template>


<script lang="ts" setup>
import { defineProps, withDefaults, computed, ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts/core'
import { PieChart } from 'echarts/charts'
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent
} from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'

// 注册必要的组件
echarts.use([
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  PieChart,
  SVGRenderer
])

const props = withDefaults(defineProps<{
  pieData?: Array<{
    name: string
    value: number
    color?: string
  }>
  size?: number
}>(), {
  //在defineProps中如果有对象数组类型需要指定默认值只能使用箭头函数返回,不然会报错。
  pieData: () => [
    { name: 'Project 1', value: 21, color: '#0099f2' },
    { name: 'Project 2', value: 65, color: '#f7b501' },
    { name: 'Project 3', value: 14, color: '#44d7b6' }
  ],
  size: 100
})

const emits = defineEmits<{
  click: [data: string]
}>()

const chartContainer = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null

// 计算包含百分比的数据
const dataWithPercentage = computed(() => {
  const total = props.pieData.reduce((sum, item) => sum + item.value, 0)
  return props.pieData.map(item => ({
    ...item,
    percentage: (item.value / total) * 100
  }))
})

// 初始化/更新ECharts
const updateChart = () => {
  if (!chartContainer.value) return

  // 如果图表实例不存在,则初始化
  if (!chartInstance) {
    chartInstance = echarts.init(chartContainer.value, null, {
      renderer: 'svg',
      devicePixelRatio: window.devicePixelRatio
    })
  }

  const option = {
    series: [{
      type: 'pie',
      radius: ['60%', '70%'],
      avoidLabelOverlap: false,
      itemStyle: {
        borderRadius: 3,
        borderColor: '#fff',
        borderWidth: 0
      },
      label: {
        show: false,
        position: 'center',
        fontSize: 24,
        formatter: '{d}%',
        color: "{c}"
      },
      emphasis: {
        scale: true,
        scaleSize: 1,
        itemStyle: {
          shadowBlur: 1,
          shadowColor: 'rgba(0, 0, 0, 0.3)'
        },
        // 注意:这里的 label 是 emphasis 的子属性
        label: {
          show: true,
          lineHeight: 30,
          // 不使用全局 rich,在 data 中为每个项单独配置
          formatter: function (params: any) {
            // 使用数据项索引生成对应的 rich 样式名称
            return `{number${params.dataIndex}|${params.percent.toFixed(1)}}{percentSign|%}`;
          }
        }
      },
      labelLine: {
        show: false
      },
      data: props.pieData.map((item, index) => ({
        value: item.value,
        name: item.name,
        itemStyle: {
          color: item.color || undefined
        },
        // 为每个数据项单独配置强调状态下的标签样式
        // 因为显示模式切到svg后,使用options配置富文本颜色的{c} 失效,所以需要手动为每一项填写颜色
        emphasis: {
          label: {
            rich: {
              [`number${index}`]: {
                fontSize: 24,
                color: item.color, // 使用与扇形相同的颜色
                verticalAlign: 'center',
                fontFamily: 'Alibaba-PuHuiTi, sans-serif'
              },
              percentSign: {
                fontSize: 7,
                color: 'grey'
              }
            }
          }
        }
      }))
    }]
  }

  chartInstance.setOption(option)
  chartInstance.resize() // 确保图表尺寸正确
}

// 监听props变化
watch(() => [props.pieData, props.size], () => {
  updateChart()
}, { deep: true })

// 处理点击事件
const onClick = () => {
  emits('click', '')
}

onMounted(() => {
  updateChart()

  window.addEventListener('resize', () => {
    chartInstance?.resize()
  })
})

onUnmounted(() => {
  chartInstance?.dispose()
  window.removeEventListener('resize', () => {
    chartInstance?.resize()
  })
})
</script>

<style lang='scss' module>
@font-face {
  font-family: 'Alibaba-PuHuiTi';
  src: url('./assets/fonts/Ayp2ydO6TAw6.woff2') format('woff2'),
    url('./assets/fonts/Ayp2ydO6TAw6.woff') format('woff');
  font-weight: normal;
  /* 常规字重 */
  font-style: normal;
}

.main {
  font-size: 10px;
  display: flex;
  align-items: center;
  /* 垂直居中对齐 */
  justify-content: space-between;
  /* 水平两端对齐 */
  gap: 10px;
  /* 图表和详情之间的间距 */


  width: fit-content;
  /* 宽度自适应内容 */
  min-width: 300px;
  /* 设置最小宽度 */
}

.chartBox {
  flex-shrink: 0;
  shape-rendering: geometricPrecision; //此处使用该项是因为该项比较原话没有很多锯齿,对饼图的清晰度有了保障。
  text-rendering: optimizeLegibility; //对饼图的强调展示下文字的清晰度渲染
}

.detailsBox {
  display: flex;
  flex-direction: row;
  gap: 5rem;
  /* 详情项之间的间距 */
  flex: 1;
  /* 占据剩余空间 */
  min-width: 0;
  /* 允许内容收缩 */
}

.detailItem {
  display: flex;
  flex-direction: column;
  align-items: center;
  flex: 1;
  /* 平均分配空间 */
  min-width: min-content;
  /* 允许内容收缩 */
}

.detailText {
  margin-bottom: 0.5rem;
  /* 减小底部间距 */
  color: #666;
  white-space: nowrap;
  /* 防止文本换行 */
  overflow: hidden;
  /* 隐藏溢出内容 */
  text-overflow: ellipsis;
  /* 溢出显示省略号 */
  max-width: 100%;
  /* 限制最大宽度 */
}

.detailPercentage {
  font-style: normal;
  text-align: left;
  overflow: auto;
  font-weight: 400;
  font-size: 24px;
  line-height: 32px;
}
</style> 

通过本次的自定义组件的封装我回顾很多在学习Vue时的方法,例如很久没用的props传参和watch监听数据,他们对于页面渲染和状态管理至关重要可以帮我们更灵活的处理一些突发情况。这次的组件模板必须通过pnpm dev,所以不得不使用pnpm里开发,就有了我第一次使用pnpm,感受到了pnpm的好处。最后就是使用了scss,了解了基础用法就能拿scss 开发了。

posted @ 2025-07-07 10:02  Happy_Eric  阅读(41)  评论(0)    收藏  举报