ARTICLE DETAIL

资讯详情

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

ECharts实现3D饼图旋转效果:基于echarts-gl的曲面拼接方案

ECharts实现3D饼图旋转效果:基于echarts-gl的曲面拼接方案 做 3D 可视化这么多年我越来越确信一个判断数据可视化做久了老板和客户的需求会惊人地一致。2D 饼图虽然好用但放到大屏汇报场景里总显得不够“显眼”。于是“echarts 3d饼图带3d旋转效果”这种需求就成了中后台项目里非常常见的标配。网上搜这类方案答案五花八门有人推荐 three.js有人建议用 CSS 3D 模拟还有人说直接用 ECharts 的 surface 系列自己拼。我对这事的态度很明确如果你已经在用 ECharts那最顺手的方案就是基于 echarts-gl 来拼。它本质上是 three.js 的封装但保留了 ECharts 的 option 配置习惯、tooltip 机制和事件模型数据接进来就能用不需要从零搭场景。这篇文章我就把 echarts 实现 3D 饼图、并且带 3D 旋转效果这件事完整拆开。会讲清楚原理、曲面参数方程、旋转配置、完整 demo以及我实际踩过的坑。文章有点长建议先收藏再动手。注意这里面最核心的坑echarts-gl 本身没有内置 pie3D 系列你要么用社区封装插件要么自己用 surface 曲面把每个扇形“拼”出来。我下面讲的就是自己拼的方案只有理解了这套逻辑你才敢在项目里随便定制。1. 为什么费劲做 3D 饼图1.1 可视化场景中 3D 饼图的真实需求先说一个最容易被忽略的问题你真的需要 3D 饼图吗我自己的判断标准很简单数据分类不超过 12 个且重点是“展示整体分布结构”而不是“精确对比数值”那 3D 饼图就是加分项。比如大屏上的市场份额分布、收入构成、流量来源占比这些场景下观众第一眼要的是冲击力不是拿尺子量角度。反过来如果数据有 18 个分类或者用户需要在饼图里精确读出百分之几的差异那我劝你别做 3D。3D 透视会压缩远处扇区的视觉面积人眼对立体角度本身就不敏感硬去做只会让信息丢失。我见过不少人做完 3D 饼图后被业务方吐槽“看不出哪个大”那不是实现的问题是设计的问题。1.2 技术选型对比为什么不是 three.js也不是伪 3D实现 3D 饼图常见有这几条路用 three.js 裸写自由度最高渲染质量也能做得很高。但问题是你需要自己管理场景、灯光、相机还要把 ECharts 的数据联动、tooltip、事件机制全都重新实现一遍。对于一个“图表组件”来说这个成本太高而且后面维护的人大概率会骂街。用 CSS 3D transform 做伪 3D只能实现固定角度的倾斜转起来很生硬透视也不真实。适合做个静态效果图不适合做可交互的图表。用 echarts-gl这是承接 ECharts 数据模型和 three.js 渲染能力之间的桥。你不需要了解 WebGL 细节还是用 series 配置项的形式写图表但它背后是真 3D 场景可以自由旋转、缩放、打光。社区里也有现成的「echarts 3D饼图插件」本质就是我这个方案的封装。插件拿过来能快速出效果但一旦要改高度、加间隙、换动画、接事件你还是得回去理解曲面怎么拼。这篇的正文讲的就是最底层那套逻辑。1.3 一个 3D 饼图到底由哪些曲面组成这是整个方案能不能做出来的关键认知。普通 2D 饼图里一个扇形就是一个 Path 图形。但切换成 3D 后一个扇形块是一个立体几何体它不是一个 series 能直接画出来的。任何一个形状规则的立体块在 echarts-gl 中都可以用多个 series-surface 拼接。一个完整的 3D 扇形块由五部分组成顶面一个扇环曲面平面位于 z 轴高度处。底面同样形状的扇环曲面位于 z0 处。外弧面圆柱外侧壁的一段也就是绕着圆弧形成的“围墙”。内弧面当内半径大于 0 时出现的内侧围墙。左右两个径向切面位于起始角度和结束角度处连接内外半径的“隔断”。这么说你可能觉得复杂但只要理解了曲面参数方程这五个面其实就是一个模板函数的五次调用。整个 3D 饼图本质上是“多个 series-surface 组装的立体拼图”series 数量大约等于数据项数量的 2 到 5 倍。这是 echarts-gl 方案最绕的地方也是你读完这篇文章后能超越插件使用者的地方。2. 搭好基础环境echarts echarts-gl2.1 版本匹配与引入方式直接说版本结论别再踩坑。echarts-gl 目前有两个大版本echarts-gl1.x对应echarts4.xecharts-gl2.x对应echarts5.x。如果你用echarts5却装了echarts-gl1大概率会报出一堆莫名其妙的错误什么Cannot read property xxx of undefined之类。用 npm 方式的话推荐组合是npm install echarts5.4.3 echarts-gl2然后在你初始化图表的地方引入并注册import * as echarts from echarts; import echarts-gl;如果你是简单页面不想搞构建工具用 CDN 最省事script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-gl2/dist/echarts-gl.min.js/script注意引入顺序先 echarts再 echarts-gl。反了的话 echarts-gl 找不到 echarts 的挂载对象注册会失败。2.2 用 surface 画一个能转的“圆饼坯”在正式拼扇形之前我强烈建议你先跑通一个最小示例用 series-surface 画一个普通的圆饼确认 echarts-gl 已经生效。var chart echarts.init(document.getElementById(chart)); chart.setOption({ series: [{ type: surface, parametric: true, shading: lambert, parametricEquation: { x: function (u, v) { return v * Math.cos(u * Math.PI * 2); }, y: function (u, v) { return v * Math.sin(u * Math.PI * 2); }, z: function (u, v) { return 0.6; } }, viewControl: { alpha: 25, beta: 0, autoRotate: true, autoRotateSpeed: 5, distance: 250 } }] });这里有几个关键点type: surface表示曲面图parametric: true表示使用参数方程来定义曲面。parametricEquation接收两个参数 u 和 v它们的取值范围默认是 0 到 1你需要在函数内部把它们映射成实际的三维坐标。上面的代码里u 被映射成了 0 到 2π 的角度v 被映射成 0 到 1 的半径z 固定为 0.6所以出来的就是一个扁平圆柱体。运行后你应该能看到一个圆饼在自动旋转。如果看不到先检查有没有报错再检查是不是把shading设置成了默认的color——默认的 color 着色不做光照计算整个面看起来是平的体感很差。设成lambert后曲面会有明暗变化旋转时能明显看出立体感。这一步跑通了后续就只是把“一个整圆饼”切成“多个扇形块”。3. 核心实现用参数曲面拼出 3D 扇形3.1 参数曲面方程角度、半径、高度如何映射在写代码之前先把数学关系想明白。一个扇形块可以理解为扇环柱体的一段它的位置由角度和半径决定高度由 z 轴决定。我用一张“参数分配表”来记忆省得每次写混乱了曲面u 参数含义v 参数含义自变量取值范围映射顶面角度 θ半径 rθ 从起始角到结束角r 从内径到外径底面角度 θ半径 r同上外弧面角度 θ高度 zθ 从起始角到结束角z 从底到顶内弧面角度 θ高度 z同上侧面半径 r高度 zr 从内径到外径z 从底到顶以顶面为例参数方程写出来是这样x r * cos(θ) y r * sin(θ) z height对应到 echarts-gl 的 parametricEquation 里就是// 顶面 parametricEquation: { x: function (u, v) { var theta startAngle u * (endAngle - startAngle); var r innerR v * (outerR - innerR); return r * Math.cos(theta); }, y: function (u, v) { var theta startAngle u * (endAngle - startAngle); var r innerR v * (outerR - innerR); return r * Math.sin(theta); }, z: function () { return height; } }这样 u 负责扫角度v 负责扫半径。只要把 startAngle、endAngle、innerR、outerR 换成你自己的值就能生成任意位置、任意大小的扇环顶面。新手最容易绕晕的地方是同一个 u/v 在不同曲面上代表不同维度。写外弧面的时候u 还是角度v 已经从“半径”变成了“高度”。如果沿用顶面的思路把 v 当成半径外弧面就会变成一个封闭的扇环壳而不是一段弧墙。3.2 逐面生成顶面、底面、外弧面与侧面的数学表达下面把五个面的参数方程全部列出来。假设变量如下startAngle扇区起始角度弧度endAngle扇区结束角度弧度innerR内半径outerR外半径height扇区高度顶面z 固定为 height。x (innerR v * (outerR - innerR)) * Math.cos(startAngle u * (endAngle - startAngle)); y (innerR v * (outerR - innerR)) * Math.sin(startAngle u * (endAngle - startAngle)); z height;底面和顶面完全一样只是 z 固定为 0。外弧面半径固定为 outerRz 在 0 到 height 之间变化。x outerR * Math.cos(startAngle u * (endAngle - startAngle)); y outerR * Math.sin(startAngle u * (endAngle - startAngle)); z v * height;内弧面半径固定为 innerR。x innerR * Math.cos(startAngle u * (endAngle - startAngle)); y innerR * Math.sin(startAngle u * (endAngle - startAngle)); z v * height;侧面角度固定r 从 innerR 到 outerRz 从 0 到 height。x (innerR u * (outerR - innerR)) * Math.cos(startAngle); // 或 endAngle y (innerR u * (outerR - innerR)) * Math.sin(startAngle); // 或 endAngle z v * height;实际操作中底面通常不需要画。因为 3D 饼图默认视角是从上往下倾斜看的底面几乎看不见画了只会增加 series 数量。我做项目时默认只生成顶面和外弧面只有当扇区间有较大间隙、需要露出侧面切面时才补侧面和底面。另外提醒一个细节如果内半径设成 0也就是实心饼图圆心处会成为曲面的退化点表面网格会在这个点挤成一团观感很糟。我建议内半径至少设 0.4 到 0.6做成环形 3D 饼图视觉更干净曲面也更稳定。3.3 数据批量映射与颜色分配理解了单个扇区的生成逻辑后批量生成就是纯循环问题了。核心思路遍历数据数组用累计角度 cursor 记录当前起始位置每个数据项根据占比计算 sweep 弧度也就是扫过的角度然后调用生成函数。var total data.reduce(function (sum, item) { return sum item.value; }, 0); var cursor 0; data.forEach(function (item) { var sweep item.value / total * Math.PI * 2; var startAngle cursor gap / 2; var endAngle cursor sweep - gap / 2; cursor sweep; // 用 startAngle、endAngle 生成顶面和外弧面 series });这个方案里gap 是扇区之间留出的空隙单位是弧度。比如 0.04 弧度大约是 2.3 度视觉上能看出明显的分隔线但又不会显得碎。每个扇区的颜色我会在数据里直接指定保证顶面和外弧面使用同一个色系但外弧面的颜色做加深处理。这样做的好处是旋转时能看到明显的明暗转折立体感一下就出来了。如果顶面和外弧面用同一个颜色Lambert 光照也能自动区分亮度但加深后对比更明显。我常用的方式给数据项预设 color然后用一个简单函数计算加深后的颜色。项目里如果用了主题色板可以直接复用 ECharts 的echarts.color.lift方法来做颜色变体。3.4 实战设计细节间隙、环化、明暗材质做 3D 饼图真正拉开效果差距的不是“能不能转”而是设计细节。第一扇区高度。我建议不要所有扇区都用同一高度可以把值最大的扇区做大一点其他按比例缩放。这样视觉重心更明确一眼看出谁是大头。但要注意缩放系数不要太大不然高扇区会把相邻低扇区挡住影响交互。第二材质与阴影。shading: lambert是兼顾效果和性能的选择。如果场景里还有其他 3D 组件或者你希望更精细的渲染可以考虑realistic配合light配置调整环境光和方向光。但对饼图来说没必要lambert 足够。第三间隙大小。gap 不建议超过 0.1 弧度太大会让饼图看起来像散了架的积木。如果数据项很少三四个可以留一点间隙数据项一多间隙就得减小甚至设成 0否则每个扇区都窄得像根针。第四内半径的选择。前面说过实心饼图容易出现中心曲面退化。环形结构也就是 donut 结构更稳定看起来也更现代。如果业务上非要实心可以尝试把内半径设置得非常小比如 0.01而不是 0。在绝大多数渲染情况下这个极小的空洞视觉上几乎察觉不到但能规避退化点。4. 旋转与交互让 3D 饼图“活”起来4.1 鼠标拖拽与自动旋转的配置参数echarts-gl 做旋转核心是 viewControl 组件。它本质上是控制一个三维相机的位置数据本身没有动只是相机绕着场景中心转。我常用的配置如下viewControl: { alpha: 25, beta: 0, distance: 250, autoRotate: true, autoRotateSpeed: 4, autoRotateAfterStill: 2, rotateSensitivity: 1, zoomSensitivity: 1 }参数含义解释一下参数作用说明alpha俯仰角相机与水平面的夹角值越大视角越高beta方位角相机绕 z 轴旋转的角度distance观察距离相机到场景中心的距离越大看到的越小autoRotate自动旋转开关true 时场景会绕中心慢转autoRotateSpeed自动旋转速度每秒转过的角度4 左右比较舒适autoRotateAfterStill静止后恢复自动旋转用户拖拽停止后过多少秒重新开始自动旋转rotateSensitivity拖拽灵敏度1 为默认越大转动越敏感需要注意viewControl 是挂在 series 上的配置而不是 option 顶层。如果你的 3D 饼图由多个 series 组成需要把同一个 viewControl 对象传给每个 series。如果你只给其中一个 series 设置了 viewControl其他 series 没设旋转时可能出现“只有一部分面在动”的割裂感。前面 demo 里的做法是定义一个 viewControl 对象在生成每个 series 时都挂载进去这样保证整个场景的相机状态一致。4.2 用 setOption 遥控相机按钮控制旋转自动旋转和拖拽是默认能力但很多场景还需要外部按钮控制比如点“下一项”时让饼图转到指定扇区正面点“暂停”时停止 autoRotate这时候你可以用 setOption 更新 viewControl 来实现chart.setOption({ series: [{ viewControl: { alpha: 50, beta: 90, distance: 300 } }] });这里有一个省事技巧setOption 默认是 merge 模式你只需要把要改的参数传进去没有传的 viewControl 参数会保留原有值。所以如果想精确控制视角直接传想要改变的参数即可不需要重建所有 series。实际项目里我常常把这条命令封装成一个函数接收 alpha、beta、distance 参数做成一个控制面板。演示的时候点按钮切视角效果比只靠自动旋转好很多。4.3 tooltip 高亮与点击联动3D 场景里的 tooltip 触发方式跟 2D 没有太大区别trigger: item就能用。但要注意每个 series 的 name 要设置好不然 tooltip 里显示不出扇区含义。tooltip: { trigger: item, formatter: function (params) { return params.seriesName br/数值 params.value; } }这里有一个坑因为一个扇区由多个 series 组成鼠标悬浮到顶面时触发的是顶面 series悬浮到外弧面时触发的是外弧面 series。如果每个 series 都叫不同的名字tooltip 会显示混乱。我的做法是同一个扇区的所有 series 都用同一个 name比如都叫“产品A”。同时在生成 series 时把原始数据项的 value 和 name 存到 series 的对应字段中让 tooltip 能拿到值。你想在点击某个扇区时高亮它思路是给同一个扇区的所有 series 打上相同的自定义标记比如id: pie-0。点击事件触发后拿到点击的 series id再遍历 option 里所有 series把属于同一 id 的颜色统一改成高亮色最后 setOption 重绘。chart.on(click, function (params) { var currentId params.seriesId; // 遍历所有 series找出 id 前缀相同的高亮并改变颜色 });这个方案改动起来要花几分钟但能很好地提升演示效果。如果你只是需要“指向扇区变亮”可以尝试使用 series-surface 的emphasis配置不过它在 3D 场景中的表现不够稳定我通常还是手动改颜色更靠谱。5. 完整可复现的 Demo5.1 完整代码把上面所有思路整合起来给一个可以直接复制打开的 HTML 文件。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleecharts 3D饼图带旋转效果/title style #chart { width: 800px; height: 600px; margin: 0 auto; } /style /head body div idchart/div script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-gl2/dist/echarts-gl.min.js/script script var chart echarts.init(document.getElementById(chart)); var rawData [ { name: 产品A, value: 30, color: #5470c6 }, { name: 产品B, value: 45, color: #91cc75 }, { name: 产品C, value: 15, color: #fac858 }, { name: 产品D, value: 10, color: #ee6666 } ]; var total rawData.reduce(function (sum, item) { return sum item.value; }, 0); var cursor 0; var gap 0.04; var innerR 0.6; var outerR 1.4; var height 0.8; var viewControl { alpha: 25, beta: 0, distance: 280, autoRotate: true, autoRotateSpeed: 4, autoRotateAfterStill: 2 }; var series []; function pushSurface(paramEq, color, name) { series.push({ type: surface, parametric: true, shading: lambert, name: name, viewControl: viewControl, itemStyle: { color: color }, parametricEquation: paramEq }); } rawData.forEach(function (item) { var sweep item.value / total * Math.PI * 2; var start cursor gap / 2; var end cursor sweep - gap / 2; var angleSpan end - start; // 顶面 pushSurface({ x: function (u, v) { var theta start u * angleSpan; var r innerR v * (outerR - innerR); return r * Math.cos(theta); }, y: function (u, v) { var theta start u * angleSpan; var r innerR v * (outerR - innerR); return r * Math.sin(theta); }, z: function () { return height; } }, item.color, item.name); // 外弧面 pushSurface({ x: function (u) { var theta start u * angleSpan; return outerR * Math.cos(theta); }, y: function (u) { var theta start u * angleSpan; return outerR * Math.sin(theta); }, z: function (u, v) { return v * height; } }, item.color, item.name); // 左侧面起始角切面 pushSurface({ x: function (u) { var r innerR u * (outerR - innerR); return r * Math.cos(start); }, y: function (u) { var r innerR u * (outerR - innerR); return r * Math.sin(start); }, z: function (u, v) { return v * height; } }, item.color, item.name); // 右侧面结束角切面 pushSurface({ x: function (u) { var r innerR u * (outerR - innerR); return r * Math.cos(end); }, y: function (u) { var r innerR u * (outerR - innerR); return r * Math.sin(end); }, z: function (u, v) { return v * height; } }, item.color, item.name); cursor sweep; }); chart.setOption({ tooltip: { trigger: item, formatter: function (params) { return params.seriesName 占比 params.seriesName 的扇区; } }, series: series }); /script /body /html这段代码生成顶面、外弧面、左右侧面共 4 个 series每个扇区 4 个 series。四个扇区一共 16 个 series性能完全没问题。5.2 预期效果与关键代码串讲打开页面后你应该能看到一个环形 3D 饼图在自动旋转。每个扇区由顶面和外弧面构成明显的体积感左右侧面在扇区间隙处露出深色切面旋转时能看到不同面之间的明暗变化。鼠标按住拖拽可以任意转动视角滚动滚轮可以拉近拉远。这里解释下代码里几个容易忽略的点。tooltip 的 formatter 里我通过params.seriesName拿到的就是 series 的 name而同一个扇区的四个 series 共用一个 name。这样悬浮到同一个扇区的任何一个面tooltip 显示的扇区名称都一致。如果你在数据里存了 value也可以直接通过 series 的自定义字段取出来展示。顶面里我故意没有画底面。这不是疏忽是前面的经验默认视角从上往下看底面看不见旋转到下方时你也可以通过把 alpha 调到负角度来看底面缺失但我个人觉得没必要为这种极端视角增加性能负担。如果你的场景要支持用户自由翻转到正下方那再补一个底面 series 也不迟。6. 常见问题与坑点排查6.1 引入报错与版本冲突我见过最多的报错都是版本不匹配导致的。场景一npm install echarts5 echarts-gl1页面报错如Cannot read property main of undefined。原因就是 echarts-gl 1.x 是给 echarts 4 用的两个版本之间内部 API 有变化注册时直接崩掉。换成 echarts-gl2 即可。场景二用 CDN 引入时echarts-gl 和 echarts 的顺序反了控制台报错echarts is not defined。检查一下 script 标签顺序必须先引 echarts。场景三在 Vue 或 React 项目里ES Module 导入写法不对。正确姿势如下import * as echarts from echarts; import echarts-gl;注意不能只import echarts-gl而忘记显式引入 echarts也不能在引入 echarts-gl 之后又重新引入一份 echarts这会导致 echarts 全局实例被覆盖渲染异常。6.2 曲面显示异常半边消失、裂缝、黑面如果你发现某个面只有一半或者转到一个角度后消失先检查参数方程里角度映射有没有超过 2π 的范围。当 sweep 接近 360 度时起始角和结束角过于接近甚至跨越 0 度边界就容易出现曲面坐标跳跃。裂缝问题也很常见。多个 series 拼接时相邻曲面在边界处的采样点可能没有完全重合渲染时就露出一条细缝。解决办法是在生成每个扇区时确保相邻扇区共用同一个 cursor 和 sweep 变量不要在每个扇区内部单独重新计算角度再四舍五入。如果仍然有缝可以临时把 gap 设成 0看看是不是间隙计算导致的误差。黑面问题一般是法线方向反了。surface 生成网格时法线会通过参数方程对 u、v 的偏导叉积自动计算。如果参数方程的变量顺序导致叉积方向朝内在 lambert 光照下就是黑的。调整方向的方法是交换 u 和 v 的映射语义比如让 v 参数对应角度的反向从 endAngle 向 startAngle 移动法线就会翻过来。6.3 标签与视觉引导线的替代方案做 2D 饼图时ECharts 自带的 label 和视觉引导线很方便。但到了 3D 场景series-surface 的 label 配置体验比较糟糕文字位置不可控经常被曲面遮挡。ECharts 饼图的视觉引导线也根本不支持 3D。我尝试过几种方案在 option 里配置label: { show: true }文字确实显示出来了但位置飘忽体验不行。自己用 graphic 组件绘制居中的文字绕开 label但文字不会跟着 3D 场景旋转只是贴在画布上。用 tooltip 代替视觉标签这是我最推荐的做法。鼠标指向扇区显示名称和数值干净且准确。如果你真的需要在 3D 场景里加常驻标签需要自己计算 3D 坐标到屏幕坐标的投影。这个工作量相当于写一个小型投影函数主要看你要不要为这一个功能投入这么多成本。就我的经验给客户演示时用 tooltip 就够了。6.4 性能优化与大数据量的克制3D 饼图每个扇区生成 2 到 4 个 series数据量一大series 总数就会爆炸。我做过一版 18 个扇区的 3D 饼图生成的 series 有 50 多个普通办公电脑上旋转起来明显卡顿。优化思路有三个第一能砍面就砍面。底面直接删除左右侧面只在扇区间隙明显时才保留。最简版本只保留顶面和外弧面效果依然能打。第二减少数据项。把占比小于 2% 的项合并成“其他”。3D 场景下过窄的扇区本来就很难被视觉分辨强行保留只会增加渲染负担。第三关闭或降低自动旋转速度。如果是汇报演示可以让用户手动拖拽而不是一直转性能压力会小很多。我个人的经验法则是扇区数超过 8 个就要仔细考虑是否值得做 3D。超过 12 个还是老老实实回到 2D 环形图加一个好看的渐变就行。我做 3D 饼图次数多了之后最大的体会是这项技术本身不复杂真正决定效果的是取舍。3D 饼图适合少数据、重展示的场景它靠体积感制造视觉冲击但也会牺牲数据阅读的精确性。如果你正在做一个展示类大屏数据项在 5 到 8 个之间这个方案会很出彩。如果你只是想在后台系统里加一个常规的数据看板我建议用 2D 环形图配渐变色成本更低信息传达反而更清楚。最后再分享一个小技巧做 3D 饼图时顶面用扇区主色外弧面用同色系的深色变体侧面用更深一档的颜色。这样就算不额外加材质贴图整个饼图在旋转时也会呈现出很明显的层次感。这个“三层面颜色递进”的技巧是我试了无数次之后觉得最省性能、最出效果的方案。
返回列表