news 2026/9/30 12:23:54

ECharts 3D 饼图实战:echarts-gl 参数曲面与伪3D方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ECharts 3D 饼图实战:echarts-gl 参数曲面与伪3D方案

做数据大屏的人大概都遇到过这种需求:设计稿上明明白白画着一个带厚度、带透视的 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 伪 3D2D 饼图 +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”的问题想通了,剩下的事情其实就是耐心调参。

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

FXS双出风口笼形转子选粉机:原理选型调试运维全解析

在水泥粉磨这个行当待久了&#xff0c;你会对“选粉机”三个字有特别复杂的感情。我当年为了提台时&#xff0c;把同一台磨配的选粉机换过不止一次&#xff0c;真正让我愿意坐下来把结构原理吃透的&#xff0c;是后来对标的这台FXS双出风口笼形转子选粉机。先说结论&#xff1a…

作者头像 李华
网站建设 2026/9/30 12:22:00

模型上线最后一公里:量化剪枝蒸馏与推理加速实战

最近半年我一直在处理模型上线这件事。真正折磨人的往往不是训练&#xff0c;而是把一个已经收敛好的模型放到生产环境里让它跑起来。显存不够、延迟超标、吞吐上不去&#xff0c;这些问题单靠加机器解决&#xff0c;成本实在难看。后来我把这一整套优化流程沉淀成了一个小工具…

作者头像 李华
网站建设 2026/9/30 12:20:17

TransUNet详解:融合CNN与Transformer的医学图像分割新范式

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

作者头像 李华
网站建设 2026/9/30 12:19:36

Serverless实战:从函数开发到部署调优与避坑指南

简介&#xff1a;一份系统讲解Serverless开发实战的PPT方案&#xff0c;面向云原生开发者、架构师以及希望降低运维成本的Web后端团队。内容先讲函数计算的核心特性&#xff0c;包括无需管理基础设施、实时弹性伸缩、高可用和低成本&#xff1b;再展示Web应用迁移到函数计算的体…

作者头像 李华
网站建设 2026/9/30 12:19:28

猫番阅读|官网入口如何正确的打开最新版本?

猫番阅读像一扇被轻轻推开的纸门&#xff0c;门后并非喧闹的广场&#xff0c;而是一条由文字、图像与想象共同铺成的小径。读者可以依照自己的兴趣寻找漫画、文字作品或连载章节&#xff0c;也能把暂未读完的内容留在书架之中&#xff0c;等待下一次重新翻开。开始使用前&#…

作者头像 李华
网站建设 2026/9/30 12:17:21

Avaya SIP中继配置指南:从原理到实战排错

简介&#xff1a;这是 Avaya Aura Communication Manager 5.2 版新功能的官方说明文档&#xff0c;面向企业通信系统运维工程师、SIP 集成实施人员以及技术决策者&#xff0c;用于快速掌握 Avaya 统一通信平台的关键更新。文档系统梳理了被叫方排队自动回叫、Avaya Installatio…

作者头像 李华