做数据可视化大屏的时候,产品经理递过来一张参考图,上面是一个能缓慢旋转的3D饼图,旁边还配了一个环形图。我下意识觉得这事不难,毕竟vue项目里用echarts画饼图几乎是标配,结果翻完官方文档才发现,原生echarts的饼图只支持2D,所谓3D饼图和环形图并不在官方正式功能列表里。这篇文章就记录一下我在vue项目中用echarts配合echarts-gl把3D饼图和环形图完整实现的过程,包括背后的几何原理、可直接复用的代码、以及调试时踩过的坑。
适合谁看?打算在vue可视化项目里做3D饼图或环形图,但不想直接上Three.js重写一套渲染逻辑的同学。核心思路是把每个扇区当成一个三维几何体,用echarts-gl的surface系列参数化曲面一块块拼出来,代码量不大,效果却足够应付大屏展示和基础交互。
1. 方案选型:为什么不直接用原生echarts画3D饼图
1.1 echarts原生2D饼图和3D需求的差距
echarts的2D饼图非常成熟,radius、startAngle、labelLine、tooltip、图例联动这些配置都很完善,做普通图表基本开箱即用。但到了3D阶段,问题就来了:echarts官方没有提供pie3D这样的系列类型,搜索官方文档和issue列表,能找到的多是“3D饼图还在规划中”“建议使用echarts-gl扩展”这类回复。这就意味着,3D饼图本质上不是echarts原生能力,需要借助WebGL渲染方案自己拼。
echarts-gl是echarts团队维护的WebGL扩展,提供了3D地图、3D散点图、3D柱状图、曲面图、球体图等能力。其中surface系列是解决3D饼图的关键,它允许你用参数方程定义一张曲面,把u、v两个参数映射到x、y、z三个坐标轴。换句话说,我们可以用数学方式精确描述“一个扇区”的顶面、底面、侧面、弧面,然后把它们组装成一个完整的3D几何体。
1.2 两种常见实现方案对比
在决定用surface之前,我还调研过另一条路:bar3D方案。这个思路是把饼图拆成大量离散的小柱体,用柱坐标换算成笛卡尔坐标后交给bar3D逐个渲染。举个简单例子,一个360度的饼图可以分成144根小柱子,每根柱子离圆心的距离和高度相同,只是角度不同,拼在一起视觉上就接近一个3D圆柱饼。
bar3D方案的优点是代码直观,只要会循环生成坐标就行,不需要理解参数方程;缺点是离散度不好控制,柱子数量太少边缘锯齿明显,数量太多性能直线下降,而且每个小柱体之间的缝隙处理起来很麻烦。surface方案则是用真正的曲面去描述扇区,视觉上更圆润,面和面之间没有接缝,一次渲染的网格数量也更可控,所以我最终选择了surface。
1.3 最终方案的取舍理由
选surface方案的另外一个原因是可以和环形图共用同一套几何逻辑。环形图无非是每个扇区多了一个内径,也就是中间挖空,其他结构和实心饼图完全一致。我只需要在构建曲面的函数里加一个innerRadius参数,值为0时是饼图,大于0时是环形图,后续不管产品需求怎么变,代码都能一套搞定。
2. 环境准备:在vue项目中接入echarts与echarts-gl
2.1 安装与版本锁定
在vue项目里安装依赖很简单,但版本坑比较多,这里先说清楚我的选择。
npm install echarts@5.4.3 echarts-gl@2.0.9echarts-gl的版本和echarts主版本强相关,2.x系列对应echarts 5,1.x系列对应echarts 3和4。如果不小心装了老版本,很容易在初始化图表时报错或者出现surface系列无法识别的问题。我在项目里固定了版本号,避免团队其他成员安装时遇到不兼容的情况。
引入方式上,最简单的做法是:
import * as echarts from 'echarts' import 'echarts-gl'注意顺序一定要先引入echarts,再引入echarts-gl,因为echarts-gl是对echarts的扩展,需要挂载到echarts对象上。如果你用webpack构建,建议把这两行放在入口文件的最顶部,避免模块执行顺序导致扩展失败。
2.2 数据设计与坐标系转换
饼图的数据结构通常是这样:
const rawData = [ { name: '直接访问', value: 335 }, { name: '邮件营销', value: 310 }, { name: '联盟广告', value: 234 }, { name: '视频广告', value: 135 }, { name: '搜索引擎', value: 1548 } ]要做成3D扇区,第一步是把值转换成角度区间。这里我统一用弧度制,规定起始角度从-90度开始,也就是12点钟方向,然后按每个数据项在总数中的占比累加角度。转换逻辑不复杂,但有一个容易忽略的点:我们需要为每个扇区保存“起始角度”和“结束角度”这两个变量,因为后面所有曲面的参数方程都要依赖它们。
const total = rawData.reduce((sum, item) => sum + item.value, 0) let startAngle = -Math.PI / 2 rawData.forEach(item => { const ratio = item.value / total const endAngle = startAngle + ratio * Math.PI * 2 // 这里startAngle和endAngle就是后续构建曲面的区间 startAngle = endAngle })3. 核心算法:用曲面参数方程拼出三维扇区
3.1 扇区的几何拆分
理解3D饼图的关键,是把它拆成几个标准曲面。一个实心扇区有顶面、底面、外弧面和两个径向侧面,一共5个面;如果是环形扇区,还多一个内弧面,一共6个面。把这些面逐一用参数方程描述出来,再作为surface系列渲染,组合起来就是完整的3D扇区。
先约定坐标系统:在echarts-gl的3D场景里,x是横轴,z是深度方向,y是垂直向上的高度轴。也就是说,饼图平放在xOz平面上,厚度方向沿y轴。很多人第一次写的时候会把高度放在z上,结果饼图竖起来了,这个方向问题值得反复确认。
以顶面为例,它本质是一个环形扇形平面。用u控制角度从起始角到结束角,用v控制半径从内径到外径,高度固定为height,就得到顶面的参数方程。底面完全相同,只是高度固定为0。
外弧面则是圆柱侧面的一部分,半径固定为outerRadius,u控制角度区间,v控制高度从0到height。内弧面同理,半径固定为innerRadius。两个径向侧面是矩形平面,角度固定为起始角或结束角,u控制高度,v控制半径方向。这样六个面就能覆盖所有情况。
3.2 曲面参数方程的实现
下面把上面的几何描述转成代码。我封装了一个构建曲面的工具函数,放在独立的utils文件里,方便组件复用。
// src/utils/pie3d.js export function buildPie3DSeries(data, options = {}) { const { innerRadius = 0, outerRadius = 100, height = 25, startAngle = -90, colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4'] } = options const total = data.reduce((sum, item) => sum + item.value, 0) const startRad = (startAngle * Math.PI) / 180 const angleSpan = Math.PI * 2 const faceTypes = innerRadius > 0 ? ['bottom', 'top', 'outer', 'inner', 'startSide', 'endSide'] : ['bottom', 'top', 'outer', 'startSide', 'endSide'] const series = [] let currentAngle = startRad data.forEach((item, index) => { const ratio = item.value / total const sectorStart = currentAngle const sectorEnd = currentAngle + angleSpan * ratio const color = colors[index % colors.length] faceTypes.forEach(face => { series.push(buildFaceSeries(face, { startAngle: sectorStart, endAngle: sectorEnd, innerRadius, outerRadius, height, name: item.name, color })) }) currentAngle = sectorEnd }) return series } function buildFaceSeries(face, opts) { const common = { type: 'surface', parametric: true, silent: true, name: opts.name, itemStyle: { color: opts.color, opacity: 0.92 }, parametricEquation: { u: { min: 0, max: 1, step: 1 / 32 }, v: { min: 0, max: 1, step: 1 / 32 } } } const { startAngle: s, endAngle: e, innerRadius: ir, outerRadius: or, height: h } = opts const angle = u => s + (e - s) * u const radius = v => ir + (or - ir) * v if (face === 'top' || face === 'bottom') { const y = face === 'top' ? h : 0 return { ...common, parametricEquation: { ...common.parametricEquation, x: (u, v) => radius(v) * Math.cos(angle(u)), y: () => y, z: (u, v) => radius(v) * Math.sin(angle(u)) } } } if (face === 'outer' || face === 'inner') { const r = face === 'outer' ? or : ir return { ...common, parametricEquation: { ...common.parametricEquation, x: (u, v) => r * Math.cos(angle(u)), y: (u, v) => h * v, z: (u, v) => r * Math.sin(angle(u)) } } } const fixedAngle = face === 'startSide' ? s : e return { ...common, parametricEquation: { ...common.parametricEquation, x: (u, v) => radius(v) * Math.cos(fixedAngle), y: (u, v) => h * u, z: (u, v) => radius(v) * Math.sin(fixedAngle) } } }这套函数的优点在于:饼图和环形图用的是同一套代码,环形图只是多渲染一个内弧面,并且把顶面和底面的内径设为大于0的值。数据的总占比会自动换算成角度区间,不需要手工计算。
4. 完整实现:vue3中的3D饼图组件
4.1 组件代码
接下来是完整的vue3组件。我保留了自定义图例、点击高亮、自定义tooltip和环形切换这几个常用交互,可以直接复制到项目里改造。
<template> <div class="pie3d-wrap"> <div ref="chartRef" class="pie3d-chart"></div> <div class="pie3d-legend"> <span v-for="item in legendData" :key="item.name" class="legend-item" :class="{ active: activeName === item.name }" @mouseenter="highlightByName(item.name)" @mouseleave="clearHighlight()" > <i :style="{ background: item.color }"></i> {{ item.name }}({{ item.percent }}%) </span> </div> <div v-if="tooltip.visible" class="pie3d-tooltip" :style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }" > {{ tooltip.name }}:{{ tooltip.value }}({{ tooltip.percent }}%) </div> </div> </template> <script setup> import * as echarts from 'echarts' import 'echarts-gl' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { buildPie3DSeries } from '../utils/pie3d' const props = defineProps({ data: { type: Array, required: true }, ring: { type: Boolean, default: false }, height: { type: Number, default: 28 } }) const chartRef = ref(null) const activeName = ref('') const tooltip = ref({ visible: false, x: 0, y: 0, name: '', value: 0, percent: '' }) const colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4'] const legendData = computed(() => { const total = props.data.reduce((sum, item) => sum + item.value, 0) return props.data.map((item, index) => ({ ...item, color: colors[index % colors.length], percent: ((item.value / total) * 100).toFixed(1) })) }) let chart function renderChart() { if (!chart) return const series = buildPie3DSeries(props.data, { innerRadius: props.ring ? 60 : 0, outerRadius: 100, height: props.height, colors }) chart.setOption({ xAxis3D: { type: 'value', show: false }, yAxis3D: { type: 'value', show: false }, zAxis3D: { type: 'value', show: false }, grid3D: { show: false, boxWidth: 230, boxDepth: 230, boxHeight: 80, axisPointer: { show: false }, viewControl: { autoRotate: true, autoRotateSpeed: 3, alpha: 18, beta: -20, distance: 360, minDistance: 150, maxDistance: 700 }, light: { main: { intensity: 1.2, shadow: true }, ambient: { intensity: 0.4 } } }, series }) } function highlightByName(name) { const series = chart.getOption().series const nextSeries = series.map(s => ({ ...s, itemStyle: { ...s.itemStyle, opacity: s.name === name ? 0.95 : 0.1 } })) chart.setOption({ series: nextSeries }) activeName.value = name } function clearHighlight() { const series = chart.getOption().series const nextSeries = series.map(s => ({ ...s, itemStyle: { ...s.itemStyle, opacity: 0.92 } })) chart.setOption({ series: nextSeries }) activeName.value = '' } function handleResize() { chart && chart.resize() } function handleChartClick(params) { if (!params || params.seriesType !== 'surface') return const origin = legendData.value.find(item => item.name === params.seriesName) if (!origin) return tooltip.value = { visible: true, x: (params.event && params.event.offsetX || 0) + 12, y: (params.event && params.event.offsetY || 0) + 12, name: origin.name, value: origin.value, percent: origin.percent } } function handleZrClick(event) { if (!event.target) { tooltip.value.visible = false clearHighlight() } } watch(() => props.ring, renderChart) watch(() => props.data, renderChart, { deep: true }) onMounted(() => { chart = echarts.init(chartRef.value) renderChart() chart.on('click', handleChartClick) chart.getZr().on('click', handleZrClick) window.addEventListener('resize', handleResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) if (chart) { chart.dispose() chart = null } }) </script> <style scoped> .pie3d-wrap { position: relative; width: 100%; } .pie3d-chart { width: 100%; height: 520px; } .pie3d-legend { position: absolute; top: 12px; right: 12px; z-index: 10; background: rgba(255, 255, 255, 0.85); border-radius: 6px; padding: 8px 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .legend-item { display: inline-flex; align-items: center; margin: 4px 8px; cursor: pointer; font-size: 13px; color: #333; transition: opacity 0.2s; } .legend-item i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; } .legend-item.active { opacity: 1; font-weight: 600; } .legend-item:not(.active):not(:hover) { opacity: 0.7; } .pie3d-tooltip { position: fixed; z-index: 1000; background: rgba(0, 0, 0, 0.75); color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 13px; pointer-events: none; white-space: nowrap; } </style>4.2 交互控制与自适应
组件里的viewControl配置是3D图表的核心交互参数。alpha控制俯仰角,beta控制水平旋转角,distance控制相机距离,autoRotate让图表自动旋转,适合大屏展示。我这里设置的alpha是18度,也就是从斜上方看下去,饼图不会因为透视被遮挡太多;beta是-20度,相当于先水平转20度再俯视,视觉上更有立体感。
需要注意的是,3D图表的resize和2D图表一样用chart.resize,但因为WebGL渲染的特殊性,切换容器尺寸后3D场景里的相机距离不会自动调整,所以我把distance设成了固定值,避免窗口变化后图表被拉伸变形。如果你们的容器宽度不固定,可以在resize回调里同时调整viewControl.distance,让整体比例保持协调。
3D半透明带给我的教训也不少。surface系列虽然支持opacity,但对透明物体的渲染顺序并不稳定,特别是当两个扇区互相交叠时,偶尔会出现穿帮。所以除非特殊设计,我建议opacity保持在0.9以上,或者给顶部面额外做一个浅色半透明覆盖,不要大面积使用低透明度。
5. 从饼图到环形图:只改一个参数
5.1 环形图几何差异
环形图和普通饼图在视觉上最大的区别就是中间空心。在三维曲面模型里,这个差异反映在每个扇区的顶面和底面都要从“扇形平面”变成“环形扇形平面”,也就是半径从0变成大于0;同时还要多渲染一个内弧面,对应圆柱孔洞的侧面。
如果你回过头看buildPie3DSeries函数,会发现这一切已经被innerRadius参数管理了。传入0时,faceTypes不包含inner,顶底面就是实心扇形;传入60时,顶底面自动变成圆环的一部分,同时补充内弧面。这也是我把这个函数设计成数据驱动的原因,切换形态不需要改任何面片代码。
5.2 动态切换的实现
组件里我用props.ring控制形态,并通过watch监听变化重新渲染。在实际项目中,你可以放一个按钮组或者下拉框,让用户自由切换饼图和环形图:
<button :class="{ active: !ringMode }" @click="ringMode = false">饼图</button> <button :class="{ active: ringMode }" @click="ringMode = true">环形图</button>const ringMode = ref(false)然后在模板里传给组件:
<Pie3D :data="rawData" :ring="ringMode" />动态切换的体验有一点需要提前说:因为每次切换都是重新生成全部series,所以不会像2D图表那样有平滑过渡动画。如果产品一定要动画,需要在渲染前做一个角度插值的tween,让每个扇区的角度从0逐渐展开到目标值,这个逻辑会比较长,一般大屏项目用“直接切换”就够了。
环形图还有个很实用的玩法,就是中间挖空区域可以叠加一个总数或者核心指标。我通常在容器中心用绝对定位放一个div,展示“总数:2562”,这样整张图表的信息层次更清晰,也不会影响3D交互。
6. 常见问题与调试实录
6.1 白屏与版本问题
遇到最多的问题是白屏。排查顺序是:先确认echarts-gl是否正确引入,再确认控制台是否报错。最常见的原因是只装了echarts-gl但没在组件里import,或者import顺序反了。在vite项目里,import 'echarts-gl'必须放在import * as echarts from 'echarts'之后,否则扩展挂载可能失败。
版本问题也经常出现。echarts 5.5以上和echarts-gl 2.0.9的组合我实测过,能正常跑,但有时会有deprecated警告。如果你们用的是更早的echarts 4,就必须把echarts-gl降回1.x。我的建议是能不升级就不升级,锁定我开头给的版本组合,等官方3D饼图支持更完善之后再统一迁移。
如果出现Cesium、Three.js和echarts-gl同时使用的场景,要注意WebGL上下文数量限制。浏览器一般支持8到16个WebGL上下文,超出后会直接拿不到渲染环境。这时候要么减少3D实例数量,要么在组件销毁时确保调用dispose释放资源,不要只是在路由离开时销毁vue实例。
6.2 标签与tooltip的替代方案
这是使用surface方案最大的痛处:3D曲面系列没有像2D饼图那样开箱即用的label配置,也不支持原生tooltip。我在组件里用两种方式解决:一是右上角放一个HTML图例,点击或hover时通过修改series的opacity实现高亮;二是监听click事件,在点击扇区时读取seriesName,再弹出一个自定义tooltip浮层。
如果产品经理一定要在3D扇区上显示文字,比如在每个扇面中心显示百分比,我有两个建议。第一个是用CSS2DRenderer之类的方案,把普通HTML元素投影到3D坐标上,但需要额外引入渲染器,成本较高。第二个更务实,把图例放在图表旁边,用颜色和名称对应,信息密度足够且实现成本最低。我在实际项目里选的是后者,在大屏上展示效果反而更干净。
另外还有一个细节:从完整组件里可以看出,每个扇区被拆成了多个surface系列,它们的name都相同。这样带来的好处是,点击任何一个面都能准确知道属于哪个数据项,但代价是series数量会膨胀。数据项少的时候没问题,如果数据项超过15个,建议减少面片数量,比如去掉底面或者增加step的步长,否则初始化会明显变慢。
6.3 性能优化与避坑清单
下面把我在调试过程中整理的避坑点汇总成一张表,方便直接对照排查。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 图表白屏,控制台无报错 | echarts-gl未正确引入 | 在echarts后import 'echarts-gl' |
| 报错series.surface找不到 | echarts-gl版本与echarts不匹配 | 锁定echarts@5.4.3 + echarts-gl@2.0.9 |
| 饼图竖起来而不是平躺 | 高度轴用错 | 高度写y,平面坐标用x和z |
| 渲染出来是平面没有厚度 | 顶面和侧面未全部生成 | 检查faceTypes是否完整 |
| 边缘锯齿严重 | surface步长过大 | 将step改为1/48或1/64 |
| 图表很卡 | 步长过密或series太多 | 增大step,去掉底面,降低自动旋转速度 |
| 点击扇区无法区分数据 | 每个面的name不同 | 所有面统一使用item.name |
| 3D图上无法显示label | surface不支持原生label | 用HTML图例或自定义tooltip替代 |
| 切换容器大小后变形 | 相机距离固定 | resize时同步调整viewControl.distance |
| 销毁路由后内存占用高 | 未释放WebGL上下文 | 在onBeforeUnmount里调用chart.dispose |
我个人在实际操作中还有一个体会:3D饼图的“厚度”是一个容易调整过度的地方。height设太大会显得笨重,设太小又看不出立体感,我通常以图表直径的1/8到1/12作为初始值,再根据实际视觉微调。数据量在20个扇区以内、做好上述性能控制的前提下,这套方案跑60帧没有问题。如果后续需求升级成需要自由漫游、几百个模型交互的复杂3D场景,那建议还是直接上Three.js,别在echarts-gl里硬撑。