news 2026/9/29 5:15:57

ECharts实现3D饼图:echarts-gl与原生伪3D方案实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ECharts实现3D饼图:echarts-gl与原生伪3D方案实战指南

一个“3D饼图”的需求,能把一张图表从“20分钟搞定”拖成“研究两小时”。ECharts本身的饼图能力相当能打,扇形、环形、南丁格尔玫瑰图随手就来,可一旦有人提出“要带厚度、能转、有光影”的3D效果,原生图表就明显不够用了。这篇文章记录的是我在数据可视化大屏里做3D饼图的完整过程,从方案选型、几何数据生成、参数调试,到tooltip、rem适配等常见坑的解决思路,希望能给同样被这个需求卡住的同学一条能直接抄的路径。

1. 先把3D饼图的三个坑说清楚

1.1 ECharts原生能力边界

先说结论:ECharts原生并不提供“3D饼图”这个图表类型。官方饼图 series-pie 支持扇形、圆环、圆角、南丁格尔玫瑰、label引导线等一堆能力,但这一切都发生在一个2D平面上。就算你给扇区加阴影、加渐变、加扇区间的空隙,本质上还是没有几何纵深的平面图。

真正负责“3D”的是 echarts-gl 这个扩展库,它给ECharts带来了 globe、map3D、scatter3D、bar3D 这些新的 series 类型,但偏偏没有 pie3D。很多人第一次搜“ECharts 3D饼图”,搜出来的结果大多是“用 bar3D 魔改的饼图”“用平面饼图做出伪3D效果”这类方案,原因就在这里。先把这一点搞清楚,后面所有实现才不会跑偏。

1.2 两条路线的取舍

既然没有现成组件,实际项目里就分化出了两条主流路线。

第一条是用 echarts-gl 的 bar3D 去“拼”饼图。原理是把饼图的每个扇区细分成很多小份,每份用一根小柱子来表示。柱子可以设置高度、颜色、倒角,还能打光,拼起来之后就是一个有厚度的立体饼图。这条路的优点是真正的3D,可以旋转视角,视觉冲击力很强;缺点也很明显,图例联动基本失效,数据更新和动画处理要比普通饼图麻烦不少。

第二条是不引入 echarts-gl,只用原生 pie 组件做“视觉伪3D”。通常做法是在底部铺一层略大、略偏移的阴影饼图模拟厚度,再在上面叠一层带渐变的正常饼图模拟受光面。这条路的优点是开发成本极低,图例、tooltip、下钻、数据更新全部沿用原来的逻辑,稳定可靠;缺点是从物理上就不存在纵深,无论怎么转视角,它都是一个“看起来立体”的平面图。

我的经验是:to B 业务大屏、数据实时刷新、图表组件很多的场景优先选第二条;做展示稿、单图大屏、产品宣传、需要拖动旋转演示的,选第一条。不要一上来就追求“真3D”,先想清楚交付场景。

2. 用echarts-gl把饼图“捏”成3D:bar3D极坐标法

2.1 思路:扇区切成柱子,用高度表达厚度

bar3D 在 cartesian3D 坐标系里渲染的是一根根长方体柱子,柱子的位置由 x、y 坐标决定,柱子有多高由 value 的第三维决定。把饼图数据映射到这个空间里,需要三步:

第一步,计算每个扇区的角度范围。比如“搜索引擎”占比30%,那么在360度里它就应该占108度。

第二步,把每个扇区再次细分。一个扇区从起始角度到结束角度,切成 N 份,每份是一个极小的扇形,我们用一根柱子去近似它。每一根柱子的中心点,就放在这个小扇形角度中间、以及某个半径位置上。理论上分得越细,拼出来的扇区边缘越平滑。

第三步,给柱子赋高度。不同扇区可以按 value 大小映射不同的高度,这样饼图每个部分的“厚度”就不一样,视觉上会更灵动。我之前做的时候习惯把高度映射成8 + value比例 * 50,小扇区也有一个最低厚度,不至于薄成纸片。

这里有一点值得说明:因为柱子的俯视投影是正方形,而饼图扇区是圆弧形的,所以无论怎么切,边缘都必然有锯齿感。解决办法是增加切分数并让柱子足够密集。另外,如果想做“实心饼”而不是“甜甜圈”,可以在半径方向上铺多圈柱子。比如设置 loopCount = 2,内圈放一排柱子、外圈放一排柱子,饼面就会更饱满。柱子的总数量等于扇区数乘以切分数乘以圈数,性能要提前评估好。

2.2 完整可运行代码

下面这段代码是基于 echarts + echarts-gl 的完整实现。安装依赖时直接执行npm install echarts echarts-gl即可,注意 echarts 用 5.x、echarts-gl 用 2.0.x 是比较稳的组合。

import * as echarts from 'echarts'; import 'echarts-gl'; const chartDom = document.getElementById('chart'); const myChart = echarts.init(chartDom); const pieData = [ { name: '搜索引擎', value: 1240 }, { name: '直接访问', value: 780 }, { name: '邮件营销', value: 560 }, { name: '联盟广告', value: 320 }, { name: '视频广告', value: 210 } ]; const palette = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de']; function buildPie3D(pieData, radius = 80) { const points = []; const meta = []; const total = pieData.reduce((sum, item) => sum + item.value, 0); const sliceCount = 90; // 每个扇区切分成多少根柱子 const loopCount = 2; // 半径方向布置几圈柱子 let angleStart = -90; // 起始角,从12点方向开始 pieData.forEach((item, idx) => { const angleSpan = (item.value / total) * 360; const height = 8 + (item.value / total) * 50; // 高度映射 for (let i = 0; i < sliceCount; i++) { // 当前小份柱子的起始角和结束角 const a1 = ((angleStart + (angleSpan / sliceCount) * i) * Math.PI) / 180; const a2 = ((angleStart + (angleSpan / sliceCount) * (i + 1)) * Math.PI) / 180; const midA = (a1 + a2) / 2; // 小扇形中心角度 for (let j = 0; j < loopCount; j++) { // 内圈取 0.3R,外圈取 0.75R,柱子中心落在半径中点附近 const r = radius * (0.3 + 0.45 * j); const x = Math.cos(midA) * r; const y = Math.sin(midA) * r; points.push({ value: [x, y, height], itemStyle: { color: palette[idx % palette.length] } }); meta.push({ name: item.name, value: item.value }); } } angleStart += angleSpan; }); return { points, meta }; } const { points, meta } = buildPie3D(pieData, 80); const option = { tooltip: { trigger: 'item', formatter: (params) => { const info = meta[params.dataIndex]; return info ? `${info.name}<br/>${info.value}` : params.value.join(', '); } }, xAxis3D: { type: 'value', min: -90, max: 90 }, yAxis3D: { type: 'value', min: -90, max: 90 }, zAxis3D: { type: 'value', max: 60 }, grid3D: { show: false, boxWidth: 130, boxHeight: 130, boxDepth: 130, viewControl: { alpha: 35, // 俯仰角 beta: 0, // 左右旋转角 distance: 260, // 视距 autoRotate: true, // 自动旋转 autoRotateSpeed: 4 }, light: { main: { intensity: 1.1, shadow: false }, ambient: { intensity: 0.5 } } }, series: [ { type: 'bar3D', data: points, barSize: 2.8, // 柱子宽度,影响拼接密度 bevelSize: 0.4, // 倒角尺寸 bevelSmoothness: 4, // 倒角平滑度 shading: 'lambert', // 光照模型 itemStyle: { opacity: 0.96 } } ] }; myChart.setOption(option);

把这段代码跑起来,看到的是一块悬浮在空间里的“厚饼”,不同扇区有不同高度,鼠标拖拽可以旋转视角。官网的在线编辑器可以直接跑 echarts-gl,新手调试这类3D效果比本地起工程快很多。

2.3 参数调优和视觉打磨

这套方案能不能出效果,几乎全看参数。我第一次做的时候特征就是“远看像一堆积木”,后来挨个调参才明白这里面的门道。

扇区切分数 sliceCount 直接决定拼接细腻度。建议最小值不要低于60,我常用90到120。切分数太高会显著增加柱子数量,导致渲染变卡。barSize 和切分数要配合着调,barSize 太小柱子之间会有明显间隙,饼面会“漏风”;太大柱子又会相互重叠,边缘糊成一片。正常情况下,半径80、切分数90时,barSize 设在2到4之间比较稳妥。

loopCount 控制半径方向上的圈数。想要“实心饼”,2到3圈就够;只做一圈也可以,视觉上更像“厚底甜甜圈”,看甲方喜好。但圈数每加1,柱子总数就翻一倍,性能压力会成倍增加。

shading 要选 lambert,不要选 realistic。realistic 的光照计算开销大,容易掉帧,在大屏场景下根本不划算。光照强度上,主光源 intensity 到1.0到1.2最舒服,太高扇区会白成一片,太低又会显得脏。bevelSize 我习惯给0.3到0.5,柱子边缘有一点点圆角,立体感立刻上去一个档次。

视角方面,alpha 给30到40、beta 给0,就是从正前方偏上一点点俯视,这是新闻里3D饼图最常见的角度,信息面积最大、变形最小。如果甲方要求自动旋转,autoRotateSpeed 给3到5就行,转太快看久会晕。

3. 不想引入GL?2D饼图也能伪出立体感

3.1 双层偏移+渐变造厚度

bar3D方案虽然效果硬核,但有一个绕不开的痛点:图例联动失效。因为series里是一个个柱子,不是一个扇区,ECharts的legend认不出“这组柱子属于搜索引擎”。业务方要是想快速查看某一项、点击图例参与联动,这个方案就得打折扣。

所以我更常推荐第二套方案:原生pie组件,通过“阴影层 + 渐变层”叠加,做出立体效果。思路其实很简单。

先铺一层底色饼图,radius 设得比主图大几个百分点,center 往下偏移一点点,颜色用很淡的灰或深色低透明度,这层用来模拟侧边厚度。再铺主饼图,radius 略小、center 居中,每个扇区用垂直方向的线性渐变填充,上方亮、下方暗,模拟受光面和背光面。两层叠加以后,视觉上就是一块“有厚度、有光照”的立体饼图。

const palette = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de']; function gradientFill(baseColor) { // 垂直渐变:上方保持亮色,下方加深模拟厚度阴影 return new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: baseColor }, { offset: 1, color: 'rgba(0,0,0,0.55)' } ]); } option = { tooltip: { trigger: 'item' }, series: [ { name: '厚度阴影', type: 'pie', radius: '62%', center: ['50%', '52%'], data: pieData.map(d => ({ name: d.name, value: d.value })), silent: true, label: { show: false }, itemStyle: { color: 'rgba(0,0,0,0.25)' } }, { name: '主饼图', type: 'pie', radius: '58%', center: ['50%', '50%'], data: pieData.map((item, idx) => ({ name: item.name, value: item.value, itemStyle: { color: gradientFill(palette[idx]) } })), label: { show: true, formatter: '{b}\n({d}%)' }, labelLine: { length: 18, length2: 12 }, emphasis: { scale: true, scaleSize: 6 } } ] };

看起来是不是像双层汉堡?这就是2D伪3D的基本盘。实际项目里,我还会在底层再叠一层更大的浅色饼图做“地面投影”,透明度压到0.1左右,立体感会更柔和。

3.2 适用场景和边界

这套方案的优点是开发成本极低,交互完整。图例点击、数据高亮、下钻、tooltip、动画全部走ECharts原生逻辑,你不需要给业务方解释“为什么点击图例没反应”。数据更新也比较省心,直接用 setOption 换 data 就能跑。大屏项目里如果只是要一个“视觉上差不多的3D饼图”,我强烈建议从这套方案开始。

边界也很清楚:它不是真的3D。无论怎么配置,它只能从一个固定角度观看,不能拖动旋转。有人尝试给外层容器加 CSS 的 perspective 和 rotateX 来做倾斜视角,我也试过,效果并不理想,因为canvas内部的文字、标签会跟着形变,看起来是糊的,不建议这么干。

4. 常见问题与调优实录

这一节把我在实际过程中踩过的坑和查到的解决方案集中整理出来,很多都是社区里反复出现的高频问题。

4.1 tooltip不换行、显示不了业务值

先说不换行。原生pie的tooltip,默认是直接把 name、value、percent 拼在一行里,名称一长就会撑爆提示框。解决方法是 formatter 里手动插入换行符,并且用 extraCssText 控制提示框的宽度和换行规则。

tooltip: { trigger: 'item', formatter: (params) => `${params.name}<br/>数值:${params.value}<br/>占比:${params.percent}%`, extraCssText: 'max-width:200px;white-space:normal;word-break:break-all;' }

再说 bar3D 方案的tooltip。因为series里的每一个数据项是一根柱子,而不是一个扇区,params.value 默认是[x, y, height],业务上的原始数值拿不到。我的做法是在生成柱子的同时维护一个meta数组,通过 params.dataIndex 去meta里反查原始数据。具体实现见上面2.2的完整代码。

4.2 vue3 + rem适配图表失效

这个热搜词对应的场景我太熟了。vue3项目里一般会用 postcss-pxtorem 把 css 里的 px 自动转成 rem,结果发现ECharts图表里的字体、图形大小完全不跟随屏幕缩放。

原因很简单:postcss-pxtorem 只处理CSS样式,而ECharts在 init 时读取的是容器元素的像素宽高,后续绘制的所有图形、文字都发生在canvas内部,根本不会经过postcss。所以“rem对图表没效果”不是bug,是ECharts的绘制机制使然。

解决方案分两层。第一,图表的容器不要用固定px,用百分比、vw或者flex布局,保证它在不同分辨率下能拿到合理的宽高。第二,监听窗口变化后调用 chart.resize(),必要时重新计算基于容器宽度的字体大小。大屏场景下,我会把根字号按设计稿比例动态调整,然后 chart.resize() 前先重新 setOption 刷新一遍 textStyle 相关的配置,这样能保证文字大小跟着整体缩放走。

4.3 labelLine小圆点偏移

这个现象在普通饼图和伪3D方案里都会碰到:扇区标签的引导线末端那个小圆点,离文字或者扇区边缘忽远忽近,位置很怪。

大多数情况下是 avoidLabelOverlap 在起作用。ECharts 开启这个选项后,会自动调整标签位置避免重叠,调整过程中引导线的锚点就会跟着偏移。 解决思路是:如果扇区不多、文字不密集,直接关闭 avoidLabelOverlap;如果必须开启,就把 labelLine 的 length 和 length2 调大一些,给标签避让留出更多空间。还可以给 label 设置 edgeDistance,控制标签与饼图边缘的距离,减少引导线拐弯造成的视觉错位。

bar3D方案里没有labelLine这个概念,因为每个柱子就是一个数据项。如果要在3D场景中显示文字,我是建议直接用 tooltip 和 legend 做信息展示,不要硬上 label,3D旋转时文字跟随的成本很高,效果也不稳定。

4.4 大屏性能与交互设置

bar3D 方案的性能是大屏场景里必须提前考虑的问题。柱子的总数等于扇区数乘以 sliceCount 再乘以 loopCount,按5个扇区、90份切割、2圈计算,就是900根柱子,这在多数设备上已经够用。但如果扇区数超过10个,我建议 sliceCount 降到50、loopCount 设为1,先保住帧率再说。

grid3D.viewControl 里的旋转能力在展示场景也要“收着点”。自动旋转开起来很炫,但业务方在看数据时往往会嫌干扰,我一般会做成“默认静止、鼠标悬浮可旋转”的模式,也就是把 autoRotate 设为 false,然后通过 rotateSensitivity 控制用户旋转的灵敏度。alpha 和 beta 建议锁在第一视角,避免用户不小心拖到奇怪的角度后回不去。

另外,light.main 里的 shadow 默认关掉就好。3D柱状图开启阴影后,需要实时计算投影,帧率会明显下降,而它对“这是不是个饼图”这个核心判断的帮助又很小,得不偿失。

问题常见原因解决方向
tooltip不换行、宽度固定formatter未处理、默认样式限制formatter加换行符,extraCssText设置宽度与换行
bar3D的tooltip看不到业务值数据项是柱子坐标而非扇区数据维护meta索引,通过dataIndex反查
rem对图表无效canvas内部不经过postcss容器用百分比,监听resize后chart.resize()
labelLine末端小圆点偏移avoidLabelOverlap自动避让关闭避让或调整length、edgeDistance
3D饼图卡顿柱子数量过多降低sliceCount、loopCount,关闭shadow
点击图例无响应bar3D数据项无法按扇区聚合改2D伪3D方案或自行实现图例联动

最后说一句我从这个需求里得到的体会:如果3D饼图要放进一个数据更新频繁、图表很多的指挥大屏,我会优先选2D伪3D方案,维护成本低、图例交互完整;如果是单图展示、重点是视觉冲击,bar3D方案确实更带感,但要想清楚图例联动和数据刷新的成本。没有哪一种方案是银弹,先问清楚业务方要的是“好看”还是“能交互”,再选型,往往比闷头写代码更管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 5:15:52

Kali Linux新手入门:虚拟机安装、提权与汉化完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:15:04

MAC地址硬刷工具详解:从芯片识别到编程器实战避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:14:45

谁才是小龙虾最强数据辅助?XCrawl vs Firecrawl 配 TaoToken 深度对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:14:34

WSL2安装配置与调优:Windows上Linux开发环境实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:13:26

Claude Code常用命令速查:用TaoToken统一Key跑通配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 5:12:13

Python+wxPython打造Windows进程管理工具:从采集到打包

话不多说。前阵子公司有台 Windows 服务器动不动就卡死&#xff0c;任务管理器倒是能打开&#xff0c;可每次都要手动切到“详细信息”标签页&#xff0c;再按内存排序、找到可疑进程、右键结束&#xff0c;一天来好几趟&#xff0c;手都酸了。更麻烦的是&#xff0c;领导还要我…

作者头像 李华