ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue项目实战:基于ECharts与ECharts-GL绘制3D饼图和环形图

Vue项目实战:基于ECharts与ECharts-GL绘制3D饼图和环形图 做数据可视化大屏的时候产品经理递过来一张参考图上面是一个能缓慢旋转的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-gl2.1 安装与版本锁定在vue项目里安装依赖很简单但版本坑比较多这里先说清楚我的选择。npm install echarts5.4.3 echarts-gl2.0.9echarts-gl的版本和echarts主版本强相关2.x系列对应echarts 51.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。外弧面则是圆柱侧面的一部分半径固定为outerRadiusu控制角度区间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 classpie3d-wrap div refchartRef classpie3d-chart/div div classpie3d-legend span v-foritem in legendData :keyitem.name classlegend-item :class{ active: activeName item.name } mouseenterhighlightByName(item.name) mouseleaveclearHighlight() i :style{ background: item.color }/i {{ item.name }}{{ item.percent }}% /span /div div v-iftooltip.visible classpie3d-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; } /style4.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 } clickringMode false饼图/button button :class{ active: ringMode } clickringMode true环形图/buttonconst ringMode ref(false)然后在模板里传给组件Pie3D :datarawData :ringringMode /动态切换的体验有一点需要提前说因为每次切换都是重新生成全部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不匹配锁定echarts5.4.3 echarts-gl2.0.9饼图竖起来而不是平躺高度轴用错高度写y平面坐标用x和z渲染出来是平面没有厚度顶面和侧面未全部生成检查faceTypes是否完整边缘锯齿严重surface步长过大将step改为1/48或1/64图表很卡步长过密或series太多增大step去掉底面降低自动旋转速度点击扇区无法区分数据每个面的name不同所有面统一使用item.name3D图上无法显示labelsurface不支持原生label用HTML图例或自定义tooltip替代切换容器大小后变形相机距离固定resize时同步调整viewControl.distance销毁路由后内存占用高未释放WebGL上下文在onBeforeUnmount里调用chart.dispose我个人在实际操作中还有一个体会3D饼图的“厚度”是一个容易调整过度的地方。height设太大会显得笨重设太小又看不出立体感我通常以图表直径的1/8到1/12作为初始值再根据实际视觉微调。数据量在20个扇区以内、做好上述性能控制的前提下这套方案跑60帧没有问题。如果后续需求升级成需要自由漫游、几百个模型交互的复杂3D场景那建议还是直接上Three.js别在echarts-gl里硬撑。
返回列表