做数据大屏的人大概都遇到过这种需求:设计稿上明明白白画着一个带厚度、带透视的 3D 饼图,你打开 ECharts 官方文档,series.type翻到pie,配置项从头看到尾,roseType、radius、itemStyle……就是找不到一个叫pie3D的东西。这时候第一反应通常是去搜“echarts 3D 饼图”,搜出来的结果里一半是 Highcharts 的截图,一半是贴了一段看不懂的参数方程就没了下文的回答。我自己第一次做这个需求的时候,也是在一个大屏项目上卡了整整两天,最后才把 ECharts 3D 饼图这套东西跑通并且接到真实业务数据上。
这篇就把我踩过的路完整复盘一遍。核心结论先放这儿:ECharts 本身确实没有原生 3D 饼图,但配合echarts-gl的参数曲面能力,可以用series.type = 'surface'配合parametricEquation手工构造出圆柱扇体,从而拼出一个真正带光照、带视角旋转的 3D 饼图。它适合有一定 ECharts 基础、需要在可视化大屏里交付立体饼图效果的前端同学。同时我也会给出一个更轻的“伪 3D”备选方案——当你的图要带引出标签、要跑在中低端设备上时,那个方案反而更靠谱。
1. ECharts 没有原生 3D 饼图,那这个效果究竟从哪来
1.1 先把官方能力边界确认清楚
echarts-gl这个扩展包提供的是 3D 坐标系下的几类系列:bar3D、line3D、scatter3D、surface、map3D、lines3D、scatterGL等等。你会发现里面没有pie3D,也没有sector3D。官方 gallery 里那个经典的 3D 饼图示例,拆开源码看,用的其实是surface(曲面)系列,而且是用parametric: true的参数曲面模式。
想通这一层,后面的事就顺了。饼图本来是什么?是一堆扇形。3D 饼图是什么?是一堆扇形柱体。柱体有六个面:上下两个环形扇面、内外两个弧形侧面、左右两个径向平面。只要能用参数方程把这一圈面“扫”出来,剩下的就是调角度和颜色。
顺带说一句,bar3D那条路我也试过。思路是用极坐标变换,把每一段柱体当成一根柱子去摆位置。问题在于bar3D只支持矩形截面,做出来的“饼”边缘是锯齿状的多边形拼贴,扇形弧度稍微大一点就露馅,而且内外侧面根本没有。所以这条路我劝你别走,纯浪费生命。
1.2 三条可行路线的取舍对比
在动手之前,我把当时考虑过的三种方案摆在一起做过对比,这个表你可以直接拿去说服产品经理:
| 方案 | 技术手段 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| 参数曲面法 | echarts-gl的surface+parametricEquation | 真三维、可旋转视角、有光照、能被 ECharts 生态管理 | 不能直接挂 label、参数方程调试成本高、性能一般 | 大屏主视觉、无引出标签的纯展示 |
| CSS 伪 3D | 2D 饼图 +transform: rotateX()+ 双层叠加 | 标签、图例、tooltip 全部照旧可用,性能极好 | 只能固定视角,无法真正旋转,立体感有限 | 需要引出标签的常规报表、移动端 |
| Canvas 手绘 | 自己算椭圆弧、逐层绘制 | 性能天花板最高、样式完全自由 | 没有交互、没有 tooltip、维护成本高 | 静态截图、超大数据量、极特殊样式 |
我最后的项目用的是第一条,因为甲方要求“鼠标能拖着转”。但第二个项目(一个要嵌在小程序里的报表)我就果断退回了伪 3D,因为那边要引出标签,参数曲面法在这件事上会让你非常难受。
1.3 echarts-gl 的引入姿势,这一步就有坑
安装没什么好说的:
npm install echarts echarts-gl --save关键是引入。很多同学按下面这样写,然后报Component series.surface not exists:
// 错误写法:只引了核心模块 import * as echarts from 'echarts/core'; import { PieChart } from 'echarts/charts'; import { GridComponent } from 'echarts/components'; echarts.use([PieChart, GridComponent]); import 'echarts-gl';问题出在echarts-gl依赖的是完整版 echarts 的全局命名空间,跟echarts/core的按需引入体系对不上。稳妥的写法是用完整包:
import * as echarts from 'echarts'; import 'echarts-gl';如果你确实在意体积,可以用echarts-gl拆出来的子包,但要注意echarts-gl的版本必须和echarts主版本匹配。目前echarts-gl@2.x对应echarts@5.x,混用 4.x 的 gl 会直接白屏,控制台报一堆Cannot read property 'xxx' of undefined。
注意:如果你是 Vue3 + Vite 的项目,
echarts-gl在开发环境有时会因为预构建缓存导致图表第一次渲染不出来,把node_modules/.vite删掉重新npm run dev就能解决。这个坑我遇到过一次,排查了半小时。
2. 参数曲面法:把圆柱扇体“掰开”成一张可参数化的面
2.1 先想清楚一个扇区由哪几块面组成
我们不是要画一个实心柱体,而是画它的表面。一个扇形柱体的表面可以拆成五块:上表面(环形扇面)、下表面(环形扇面)、外弧面(竖直的圆柱弧面)、内弧面(如果中间有孔的话),以及两个径向的竖直平面。
如果按传统思路,一块一块生成surface,那一个扇区就要产生 5 个 series,8 个扇区就是 40 个 series,图例管理、事件处理、性能全都会崩。
这里的巧思在于:用两个参数 u、v 把这一整圈表面“展开”成一张连续的条带,让 ECharts 自己去做网格剖分。就像把一只袜子从筒口剪开摊平,虽然形状变了,但拓扑关系还在。
2.2 参数 u 与 v 的取值域为什么是这样
先看最终会被用到的参数域:
u: { min: -Math.PI, max: Math.PI * 3, step: Math.PI / 32 }, v: { min: 0, max: Math.PI * 2, step: Math.PI / 20 }u的取值域故意开得比一个完整圆周还大(从 -π 到 3π),是为了留出“夹断”的余量。真正落在扇区角度范围[startRadian, endRadian]内的u,走的是标准圆弧公式;而超出范围的u,被强行夹到边界角度上。这个夹断动作,正好生成了扇区两侧那两个竖直的径向平面。如果你把 u 的域设成正好一个圆周,这两个侧面就会缺一块。
v则是一个完整的 0 到 2π 循环,它控制的是截面方向。径向半径用1 + Math.cos(v) * k表达,k是内径参数:
v = 0时,半径1 + k,这是外径;v = π时,半径1 - k,这是内径;v = π/2和v = 3π/2时,半径都是1,也就是内外径的中线。
同时 z 方向用Math.sin(v)的正负来区分上下:sin(v) > 0的半个循环走上表面,sin(v) <= 0的半个循环走底面。于是v从 0 走到 π 是上表面(半径从外到内),从 π 走到 2π 是下表面(半径从内回到外),在v = 0和v = π这两个临界点上 z 值发生跳变,跳变处自然被渲染成竖直的弧形侧面。
2.3 内径参数 k 的换算公式,以及那个“除零”陷阱
externalDiameterRatio(空心情形下用的是internalDiameterRatio)是我们要暴露给业务的参数,取值 0 到 1。它和k的映射关系是:
k = (1 - internalDiameterRatio) / (1 + internalDiameterRatio);推导一下你就明白了。我们要的是:内径 / 外径 =internalDiameterRatio。而参数方程给出的内径是1 - k,外径是1 + k。所以:
(1 - k) / (1 + k) = internalDiameterRatio 1 - k = r + r*k 1 - r = k * (1 + r) k = (1 - r) / (1 + r)代入验证:internalDiameterRatio = 0时k = 1,内径变成 0,实心饼图;internalDiameterRatio = 1时k = 0,内外径都等于 1,变成一条线。
这里有个必须防的坑:当internalDiameterRatio取 1 或者接近 1 时,k会趋近于 0,此时1 + cos(v) * k在 v 上几乎不变化,曲面退化成一张纸片,ECharts 剖分时会算出大量退化三角形,渲染出来是一堆黑斑。我一般在函数入口直接做一次钳制:
if (internalDiameterRatio >= 0.99) { internalDiameterRatio = 0.99; }另外internalDiameterRatio小于 0 也要钳掉,否则外径会小于内径,整个柱体会翻过来。
2.4 每个扇区的高度,到底该不该跟数值挂钩
经典实现里,h传的是这一项的数据值,而 z 轴的高度用h * 0.1来算。这样数值大的扇区会更“厚”,视觉上多了一个维度。
但我实测下来,在真实业务里这个做法经常翻车。因为分享率、转化率这类数据往往差好几个数量级,最大的那块高度可能是最小那块的一百倍,图就彻底没法看了。我的处理方式是:
- 展示型需求(甲方就是要“看起来有层次”):用数值归一化后再映射高度,把范围压到
[0.6, 1.4]之间; - 分析型需求(要传达占比):所有扇区统一高度,老老实实只靠角度表达数据。
统一高度的写法就是给每个扇区传同样的值:
seriesItem.parametricEquation = getParametricEquation( startRatio, endRatio, false, false, k, 1 // 最后一个参数固定为 1 );3. 完整可运行代码:一个能直接跑的 3D 饼图
3.1 生成 series 数组:先算比例,再反填参数方程
先看数据组装函数。它的职责是把一个普通的饼图数据数组,转换成一组带参数方程的surfaceseries:
function getPie3D(pieData, internalDiameterRatio) { const series = []; let sumValue = 0; let startValue = 0; let endValue = 0; const legendData = []; // 内径参数钳制,防止退化 if (internalDiameterRatio >= 0.99) internalDiameterRatio = 0.99; if (internalDiameterRatio < 0) internalDiameterRatio = 0; const k = (1 - internalDiameterRatio) / (1 + internalDiameterRatio); // 第一轮:累加总值 + 组装基础 series 壳子 for (let i = 0; i < pieData.length; i++) { sumValue += pieData[i].value; const seriesItem = { name: pieData[i].name || `series${i}`, type: 'surface', parametric: true, wireframe: { show: false }, itemStyle: pieData[i].itemStyle || {}, // 把原始数据挂在 series 上,后面事件里要用 pieData: pieData[i], pieStatus: { selected: false, hovered: false, k: k } }; series.push(seriesItem); } // 第二轮:算出每个扇区的起止占比,生成参数方程 for (let i = 0; i < series.length; i++) { endValue = startValue + series[i].pieData.value; series[i].pieData.startRatio = startValue / sumValue; series[i].pieData.endRatio = endValue / sumValue; series[i].parametricEquation = getParametricEquation( series[i].pieData.startRatio, series[i].pieData.endRatio, false, false, k, series[i].pieData.value ); startValue = endValue; legendData.push(series[i].name); } return { series, legendData }; }这里有个工程细节值得强调:起止占比必须用startValue / sumValue和endValue / sumValue累加得出,不能直接value / sumValue再乘索引。因为浮点累加和连乘的结果在扇区多的时候会出现累积误差,扇区之间会裂开一条缝,肉眼可见。用累加值做分子是最稳的。
3.2 参数方程本体,逐段拆开看
function getParametricEquation( startRatio, endRatio, isSelected, isHovered, k, h ) { const midRatio = (startRatio + endRatio) / 2; const startRadian = startRatio * Math.PI * 2; const endRadian = endRatio * Math.PI * 2; const midRadian = midRatio * Math.PI * 2; // 只有一个扇区时,不做选中偏移,否则整个饼会平移出去 if (startRatio === 0 && endRatio === 1) isSelected = false; k = typeof k !== 'undefined' ? k : 1 / 3; // 选中时沿扇区中位角方向向外平移 0.1 const offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0; const offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0; // 悬停时整体放大 5% const hoverRate = isHovered ? 1.05 : 1; return { u: { min: -Math.PI, max: Math.PI * 3, step: Math.PI / 32 }, v: { min: 0, max: Math.PI * 2, step: Math.PI / 20 }, x: function (u, v) { if (u < startRadian) { return offsetX + Math.cos(startRadian) * (1 + Math.cos(v) * k) * hoverRate; } if (u > endRadian) { return offsetX + Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate; } return offsetX + Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate; }, y: function (u, v) { if (u < startRadian) { return offsetY + Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate; } if (u > endRadian) { return offsetY + Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate; } return offsetY + Math.sin(u) * (1 + Math.cos(v) * k) * hoverRate; }, z: function (u, v) { // u 落在扇区外时,z 沿用边界值,形成竖直侧面的收边 if (u < -Math.PI * 0.5) return Math.sin(u); if (u > Math.PI * 2.5) return Math.sin(u) * h * 0.1; return Math.sin(v) > 0 ? 1 * h * 0.1 : -1; } }; }x和y里的三段判断是整个方案的核心:u在扇区角度范围内走cos(u)和sin(u)的真实圆弧,u小于起始角就锁定在起始角,大于结束角就锁定在结束角。锁定之后,这些点仍然会沿着v方向扫出上下两排,于是就成了两块竖直平面。这就是为什么前面强调u的域必须超出圆周范围。
z里对u的两段特殊处理,是为了在扇区的两个径向外侧补上竖直收边。落在-π/2之前和2.5π之后的u,z 值跟着sin(u)走,形成一个平滑过渡的斜边,避免出现尖锐的接缝。
3.3 option 骨架:grid3D 与 visualMap 的配置要点
const chart = echarts.init(document.getElementById('pie3d'), null, { devicePixelRatio: window.devicePixelRatio }); const { series, legendData } = getPie3D(pieData, 0.3); const option = { tooltip: { show: true, formatter: function (params) { // surface 的 params 里能拿到 seriesName return `${params.seriesName}`; } }, legend: { data: legendData, orient: 'vertical', right: 20, top: 'center', textStyle: { color: '#8FA3BF', fontSize: 12 } }, xAxis3D: { type: 'value', min: -1.5, max: 1.5 }, yAxis3D: { type: 'value', min: -1.5, max: 1.5 }, zAxis3D: { type: 'value', min: -1.5, max: 1.5 }, grid3D: { show: false, boxWidth: 200, boxDepth: 200, boxHeight: 60, viewControl: { alpha: 35, // 俯仰角 beta: 40, // 旋转角 distance: 280, // 相机距离 minAlpha: 5, maxAlpha: 90, autoRotate: false, panMouseButton: 'left', rotateMouseButton: 'right' }, light: { main: { intensity: 1.2, shadow: true, alpha: 40, beta: 30 }, ambient: { intensity: 0.4 } } }, series: series }; chart.setOption(option);几个参数的实测经验:
boxHeight控制的是 z 轴方向的空间高度,它和参数方程里的h * 0.1是乘算关系。想要饼图更厚,优先调boxHeight,别去动参数方程,因为h还牵着数据归一化。distance太小会把整个饼顶出画面,太大又显得很小很扁。280 左右是 200×200 箱体的甜点值,我试过 200 到 400 这个区间。light一定要配shadow: true,否则扇区之间完全没有遮挡关系,看上去就是一堆贴片,立体感全无。viewControl里的鼠标按键映射,默认左键旋转、右键平移在网页上会和浏览器右键菜单打架,建议改成左键平移、右键旋转,体验更接近三维软件。
3.4 在 Vue3 与 React 里的封装写法
Vue3 里最常见的坑是onMounted时容器还没拿到宽高,导致 canvas 尺寸是 0。稳妥做法是让容器有明确的宽高,并在 nextTick 之后再初始化:
import { onMounted, onBeforeUnmount, ref, nextTick } from 'vue'; import * as echarts from 'echarts'; import 'echarts-gl'; const chartRef = ref(null); let chartInstance = null; onMounted(async () => { await nextTick(); chartInstance = echarts.init(chartRef.value, null, { devicePixelRatio: window.devicePixelRatio }); chartInstance.setOption(buildOption()); window.addEventListener('resize', handleResize); }); const handleResize = () => chartInstance && chartInstance.resize(); onBeforeUnmount(() => { window.removeEventListener('resize', handleResize); if (chartInstance) { chartInstance.dispose(); chartInstance = null; } });React 的函数组件里写useEffect时,注意把echarts-gl的引入放在组件外部的模块顶层。我有一次把它写在useEffect里面,开发环境热更新之后会出现“曲面系列不生效但控制台不报错”的诡异现象,原因就是模块被重复执行时机不对。
4. 悬浮、选中与图例联动:surface 系列没给你的交互得自己补
4.1 用事件回调反查扇区索引
surface系列不像pie那样自带 hover 高亮和点击扇区弹出。这件事得完全自己接管。好在echarts-gl的事件里能拿到seriesIndex和seriesName:
let lastHoverIndex = -1; chart.on('mouseover', function (params) { if (params.seriesIndex === undefined) return; if (lastHoverIndex === params.seriesIndex) return; lastHoverIndex = params.seriesIndex; updateSeriesState(params.seriesIndex, { hovered: true }); }); chart.on('globalout', function () { if (lastHoverIndex === -1) return; updateSeriesState(lastHoverIndex, { hovered: false }); lastHoverIndex = -1; });这里用lastHoverIndex做了一层去重,因为mouseover在网格密集的曲面上一帧能触发十几次,每次都去setOption会直接把帧率打到个位数。
4.2 重算参数方程实现扇区弹出
更新状态的函数核心动作是:取出该扇区的原始起止占比,重新调用getParametricEquation,只替换这一个 series 的配置。
function updateSeriesState(index, state) { const series = chart.getOption().series; const target = series[index]; const pieStatus = target.pieStatus || {}; const isSelected = state.selected !== undefined ? state.selected : pieStatus.selected; const isHovered = state.hovered !== undefined ? state.hovered : pieStatus.hovered; const newEquation = getParametricEquation( target.pieData.startRatio, target.pieData.endRatio, isSelected, isHovered, pieStatus.k, target.pieData.value ); chart.setOption({ series: [{ name: target.name, parametricEquation: newEquation, pieStatus: { selected: isSelected, hovered: isHovered, k: pieStatus.k } }] }); }注意这里是部分更新,只传series[0]而不是整个数组。ECharts 会按name去匹配已有的系列做合并,这样曲面网格不会全量重建,性能差别相当明显。我实测过,全量setOption一次大概 40 到 60 毫秒,部分更新只要 3 到 5 毫秒,在快速划动鼠标的时候就是流畅与卡死的区别。
4.3 图例联动要自己写,别指望默认行为
legend和surface系列之间的联动,在echarts-gl里支持得并不完整。稳妥做法是监听legendselectchanged,自己过滤 series 数组:
chart.on('legendselectchanged', function (params) { const selected = params.selected; const visibleNames = Object.keys(selected).filter(k => selected[k]); // 把可见的名字对应的 series 重新组装 const allSeries = chart.getOption().series; const filtered = allSeries.filter(s => visibleNames.indexOf(s.name) > -1); chart.setOption({ series: filtered }, { replaceMerge: [] }); });这里有个非常容易忽略的细节:过滤之后必须用replaceMerge或者干脆清空重设,否则 ECharts 会把旧的 series 留在内部状态里,出现“图例关了但图形还在”的现象。我在一个项目上被这个问题坑了半天,一开始以为是 gl 的 bug,后来发现是合并策略的问题。
4.4 引出标签的两种补救方案
surface系列不能挂label,这是硬限制。如果你确实需要标签,有两条路:
第一条是自己算屏幕坐标,用 DOM 覆盖上去。用chart.convertToPixel在 3D 坐标系下不太靠谱,因为需要先做一次投影。可行的做法是调用场景的相机矩阵手动算,代码量不小,而且视角一旋转标签就全乱了。除非需求方接受“旋转时标签隐去”,否则不建议。
第二条是退回到伪 3D,这也是我后来大部分项目实际采用的方式。下一章会详细讲。
5. 大屏适配与常见坑:从 rem 失效到性能掉帧
5.1 pxtorem 为什么对 canvas 里的文字完全无效
这个坑和大屏适配强相关,也是搜“pxtorem 对 echarts 没起到效果”的人最常问的。原因很简单:postcss-pxtorem 处理的是 CSS 文件里的px单位,而 ECharts 的字体大小写在 JS 对象的fontSize里,那是一段纯数值,根本不经过 PostCSS。
所以你在option里写fontSize: 12,无论屏幕多宽,它永远是 12 个物理像素。在大屏上看着就像蚂蚁。解决方案是自己实现一层缩放因子:
// 设计稿基准宽度 1920 const designWidth = 1920; const fontScale = document.documentElement.clientWidth / designWidth; function buildOption(scale) { return { legend: { textStyle: { fontSize: 12 * scale } }, // 其他涉及 fontSize 的地方同样处理 series: series }; }更进一步,我一般会把所有字号抽到一个配置对象里,统一乘scale,然后监听resize时重新setOption。注意重新setOption时不要带上series的全部参数方程,只传字号相关的部分,避免曲面重建。
还有个更省事的办法:直接对图表容器做整体缩放。给容器一个固定尺寸(比如 1920×1080),然后用transform: scale()缩放外层包裹元素。这样 canvas 里的所有文字、线条都跟着等比例缩放,一步到位。代价是缩放倍数特别大时文字会发虚,因为 canvas 是按原始尺寸渲染后再做位图缩放的。所以我只在缩放比例在 0.6 到 1.4 之间时才用这一招。
5.2 曲面剖分的密度与帧率之间的平衡
前面参数域里的step直接决定了网格密度。Math.PI / 32和Math.PI / 20意味着单个扇区大约有(4π / (π/32)) × (2π / (π/20)) ≈ 128 × 40 = 5120个顶点。8 个扇区就是 4 万多个顶点,加上光照计算和阴影,在中端笔记本上帧率大概在 45 到 55 之间。
如果扇区数量继续增加(比如 15 个以上),帧率会明显掉。这时候有两个调节手段:
- 把
u的step从Math.PI / 32放宽到Math.PI / 16甚至/ 12。视觉上圆弧会稍微有点棱角,但在大屏的观看距离下基本看不出来。 - 关掉
light.main.shadow。阴影是所有开销里最大的一块,关掉之后帧率通常能回升 20% 以上。
真实项目里我的选择是:默认关阴影、step用/ 24,如果检测到设备不支持 WebGL2 就进一步降到/ 12。检测方式很简单:
const canvas = document.createElement('canvas'); const isWebGL2 = !!canvas.getContext('webgl2');WebGL2 不可用时还能退到 WebGL1,但如果连 WebGL1 都没有(老旧的国产化终端遇到过),那就只能整图降级成 2D 饼图加一个提示文案了。
5.3 版本兼容与构建工具的坑
echarts-gl这个包更新节奏比主库慢很多,2.0.9 之后基本没怎么动过。这意味着:
- 它内部有一些对
echarts私有 API 的调用,主库升级大版本时有概率把它搞坏。我一般会把echarts锁在5.4.x,不去追5.5+。 - 用 Vite 构建时,
echarts-gl里的require('echarts')写法在 ESM 环境下有时会报警告。可以在vite.config.js里加optimizeDeps.include: ['echarts-gl']让它走预构建。 - Webpack 4 老项目里可能需要在
resolve.alias里把echarts指向echarts/dist/echarts.min.js,否则 gl 找不到全局变量。
提示:每次升级
echarts版本之后,务必单独跑一遍 3D 图表页面。单元测试通常测不到 canvas 渲染结果,这类问题只会在肉眼看到白屏时才暴露。
6. 伪 3D 方案:当标签和性能比“真 3D”更重要时
6.1 用 CSS transform 制造透视感
说回备选方案。核心思路是:你看到的 3D 感,主要来自“椭圆压扁”和“厚度阴影”两个视觉线索,而不是真的需要三维坐标系。
做法是用两个叠加的容器。底层容器画一块比主图稍微偏移、颜色更深的饼图,模拟“厚度”;上层容器画正常的饼图,然后对整个包裹元素施加:
.pie-3d-wrapper { transform: rotateX(56deg) rotateZ(0deg); transform-style: preserve-3d; perspective: 800px; }rotateX的角度控制在 50 到 60 度之间效果最好。小于 45 度看起来还是平的,大于 65 度就扁得认不出是饼图了。
但注意,这个倾斜是施加在整个图表容器上的,意味着文字标签也会被一起倾斜,看起来非常奇怪。所以正确做法是:只倾斜绘制图形的 canvas,标签层用绝对定位的 DOM 单独渲染,保持水平。
ECharts 恰好提供了这个能力。设置label.show = false,然后用chart.on('finished')配合自定义的 DOM 标签层,或者更简单的——把标签做成rich富文本放在另一个透明饼图上,让那层单独渲染但不受 transform 影响。实际项目里我用的是前者,因为可控性更强。
6.2 用两层饼图叠出“厚度”
厚度的做法比想象中简单。准备两份数据完全相同的 option:
const baseOption = { series: [{ type: 'pie', radius: ['30%', '70%'], center: ['50%', '50%'], label: { show: false }, data: pieData }] };底层那份把每个扇区的颜色整体压暗 15%,然后让容器向下偏移 12 像素(在旋转后的坐标系里,向下偏移视觉上就是“厚度”)。上层保持原色。两层叠加之后,倾斜视角下就会看到侧面的暗边,立体感立刻出来了。
颜色压暗可以写个简单的工具函数:
function darken(hex, ratio) { const num = parseInt(hex.slice(1), 16); const r = Math.max(0, Math.round(((num >> 16) & 0xff) * (1 - ratio))); const g = Math.max(0, Math.round(((num >> 8) & 0xff) * (1 - ratio))); const b = Math.max(0, Math.round((num & 0xff) * (1 - ratio))); return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`; }ratio取 0.15 到 0.25 之间最自然,太深会像影子,太浅看不出体积。
6.3 什么时候该果断放弃真 3D
我后面做过三个类似需求,只有第一个用了参数曲面法。判断标准其实很清晰:
| 判断维度 | 选参数曲面法 | 选伪 3D |
|---|---|---|
| 是否需要鼠标拖拽旋转视角 | 是 | 否 |
| 是否需要引出标签 | 否 | 是 |
| 扇区数量 | 8 个以内 | 不限 |
| 运行设备 | 桌面端、大屏 | 移动端、低端终端 |
| 是否有快速切换数据的交互 | 否 | 是 |
| 开发周期 | 至少两天 | 半天 |
“是否有快速切换数据的交互”这条很多人会忽略。因为参数曲面法的setOption每次都在重建几何体,如果你的图要跟着日期选择器频繁刷新,每切换一次卡一下,体验会很差。伪 3D 就是普通的 2D 饼图重新渲染,流畅得多。
最后一类特殊情况:如果甲方明确要求“像某某软件那样能转”,那就只能上参数曲面,并且在合同里把性能预期说清楚。我一般会主动在需求评审时演示两个方案的对比视频,让决策发生在前期而不是交付前一天。
这套东西我前后在四个项目里用过,每次都会在参数域和光照上做点微调,但核心逻辑一次都没变过。真正需要花时间的是把交互和适配补齐,那部分代码量大概比绘制本身多三倍。等你把第一章那个“为什么官网找不到 pie3D”的问题想通了,剩下的事情其实就是耐心调参。