使用 ECharts + ECharts-GL 生成 3D 环形图
本文系统总结在项目中用 ECharts 与 ECharts-GL 手工生成 3D 环形图(Donut)的全过程:从原理、实现步骤、关键配置,到常见坑位与解决方案,以及可复用的代码片段与使用流程。
为什么选择 ECharts-GL 手工实现 3D
- 原生 ECharts 饼图是 2D;3D 需要借助 ECharts-GL 的
series.surface与参数方程自定义曲面形状。 - 手工实现可精细控制:切片厚度、内外径比例、标签引导线、视角与后处理特效(高光、SSAO等)。
- 可与数据规模、性能要求做平衡:通过参数步进控制网格密度,避免过多面元导致性能瓶颈。
适用场景:数据可视化展示、营销演示、报告图表对比;不适用场景:强交互且需要大量点击选择的复杂图表(ECharts-GL 事件交互相对有限)。
环境与依赖
echarts:基础图表库echarts-gl:3D 能力与曲面支持- 可选:
echarts-for-react(在 React 项目中便捷渲染)
安装示例:
yarn add echarts echarts-gl echarts-for-react
在 React 组件中引入:
import EChartsReact from 'echarts-for-react';
import 'echarts-gl';
原理概述:参数方程生成“环形切片”
3D 环形图的每个扇形切片本质上是一个通过参数方程描述的曲面。我们为每个数据点计算其在圆环上的起止比例(startRatio / endRatio),然后用参数方程将该角段“弯折”到环面上。
关键参数:
k:由内外径比换算得到的辅助参数,控制环的厚度(默认约1/3)。h:切片高度(厚度),与数据值成比例,避免某项过大导致“超高”。startRatio/endRatio:每个扇形在圆周上的起止比例,来源于数据总和与各项值。
我们在 getParametricEquation 中将 (u, v) 两个参数映射到三维空间 (x, y, z),并控制边界(区间外使用边缘角度),从而形成饼图扇形段的立体曲面。
实现步骤(核心流程)
- 计算比例与厚度
- 累加数据得到
sumValue,为每个数据计算startRatio/endRatio。 - 取数据最大值作为基准,按比例将值映射为高度
h:
const heightFromVal = (v: number) => {if (!maxValue || maxValue <= 0) return minH;const ratio = v / maxValue;return minH + (maxH - minH) * ratio;
};
- 为每个数据构造
series.surface
type: 'surface'、parametric: true,设置parametricEquation为曲面方程。- 将颜色与透明度通过
itemStyle.color/itemStyle.opacity传入(与 3D 视觉保持一致)。
- 标签与引导线(3D版本)
- 使用两条
line3D+ 一个scatter3D(文本)构成“引导线 + 标签”。 - 文本通过
scatter3D.label.formatter输出{name}\n{value}元两行样式。 endPosArr的计算要考虑象限(通过中径角判断朝向),以避免文本与线段穿插扭曲:
const flag = (midRadianInFirstOrFourthQuadrant) ? 1 : -1;
const endPosArr = [posX * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),posY * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),posZ * 2,
];
- 透明“支撑环”
- 额外添加一个透明
surface,用于近似实现高亮/鼠标交互的承载,避免直接与扇形交互造成干扰。
- 场景配置
grid3D控制视角与后处理:开启postEffect.bloom、SSAO增强质感,同时合理设置viewControl的旋转/缩放灵敏度(大多数展示场景禁用交互)。
完整示例(精简版)
源自项目文件 Pie3DChart.tsx,保留核心逻辑并做少量注释:
import EChartsReact from 'echarts-for-react';
import 'echarts-gl';// 支持通过 props 传入切片高度范围,默认 [8, 20]
const Pie3DChart = ({ dataList, sliceHeightRange = [8, 20] }) => {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;const offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;const offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0;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(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(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(u, v) {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;},};}function getPie3D(pieData, internalDiameterRatio, heightRange) {const [minH, maxH] = heightRange || [8, 20];let series = [];let sumValue = 0;let startValue = 0;const k = typeof internalDiameterRatio !== 'undefined'? (1 - internalDiameterRatio) / (1 + internalDiameterRatio): 1 / 3;const maxValue = (pieData || []).reduce((m, d) => Math.max(m, d?.value || 0), 0);const heightFromVal = (v) => (!maxValue ? minH : minH + (maxH - minH) * (v / maxValue));for (let i = 0; i < pieData.length; i++) {const { value = 0, name, itemStyle } = pieData[i] || {};sumValue += value;const seriesItem = {name: typeof name === 'undefined' ? `series${i}` : name,type: 'surface', parametric: true, wireframe: { show: false }, pieData: pieData[i],pieStatus: { selected: false, hovered: false, k },};if (itemStyle) seriesItem.itemStyle = { color: itemStyle.color, opacity: itemStyle.opacity };series.push(seriesItem);}const linesSeries = [];let endValue = 0;for (let i = 0; i < series.length; i++) {const { pieData } = series[i] || {};const val = pieData?.value || 0;endValue = startValue + val;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,heightFromVal(val),);startValue = endValue;// 计算标签位置与引导线(两段线)const midRadian = (series[i].pieData.endRatio + series[i].pieData.startRatio) * Math.PI;const posX = Math.cos(midRadian) * (1 + Math.cos(Math.PI / 2));const posY = Math.sin(midRadian) * (1 + Math.cos(Math.PI / 2));const posZ = Math.log(Math.abs(val + 1)) * 0.1;const flag = (midRadian >= 0 && midRadian <= Math.PI / 2) ||(midRadian >= (3 * Math.PI) / 2 && midRadian <= Math.PI * 2) ? 1 : -1;const color = pieData?.itemStyle?.color;const endPosArr = [posX * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),posY * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),posZ * 2];linesSeries.push({ type: 'line3D', coordinateSystem: 'cartesian3D', lineStyle: { color }, data: [[posX, posY, posZ], endPosArr] },{ type: 'scatter3D', coordinateSystem: 'cartesian3D', label: { show: true, formatter: '{b}' }, symbolSize: 0, data: [{ name: series[i].name + '\n' + val + '元', value: endPosArr }] },);}series = series.concat(linesSeries);// 透明支撑环series.push({ name: 'mouseoutSeries', type: 'surface', parametric: true, wireframe: { show: false }, itemStyle: { opacity: 0 }, parametricEquation: {/* ...略 */} });return {legend: { bottom: 0, icon: 'circle' },tooltip: { trigger: 'item', axisPointer: { type: 'none' } },xAxis3D: { min: -1, max: 1 }, yAxis3D: { min: -1, max: 1 }, zAxis3D: { min: -1, max: 1 },grid3D: {show: false, boxHeight: 10,viewControl: { alpha: 40, rotateSensitivity: 0, zoomSensitivity: 0, panSensitivity: 0, autoRotate: false },postEffect: { enable: true, bloom: { enable: true, bloomIntensity: 0.1 }, SSAO: { enable: true, quality: 'medium', radius: 2 } },},series,};}return <EChartsReact option={getPie3D(dataList, 0.71, sliceHeightRange)} style={{ height: '100%' }} />;
};export default Pie3DChart;
使用流程与集成
- 准备数据
const dataList = [{ name: '成本', value: 1200, itemStyle: { opacity: 0.7, color: '#4FA8A4' } },{ name: '收益', value: 250, itemStyle: { opacity: 0.7, color: '#3570af' } },{ name: '2收益', value: 158, itemStyle: { opacity: 0.7, color: '#FDAA56' } },
];
- 组件调用(React)
<Pie3DChart dataList={dataList} sliceHeightRange={[8, 20]} />
- 页面集成与 2D 标签对齐(微协同场景)
微协同页面使用 2D 饼图的 label/labelLine 控制两行标签与两段引导线长度,便于与 3D 效果保持视觉一致:
label: {show: true,formatter(params) { return `{name|${params.name}}\n{value|${formatAmount(params.value)}}`; },rich: {name: { color: '#6A7570', fontSize: 12, lineHeight: 18 },value: { color: '#6A7570', fontSize: 12, lineHeight: 18 },},
},
labelLine: { show: true, length: 12, length2: 40, lineStyle: { color: '#C2C8C5', width: 1 } },
注意事项与踩坑实录
- 性能与细节平衡
- 参数方程的步进值(
u.step,v.step)越小,曲面越精细但性能越差;在业务场景下推荐u.step ≈ π/32,v.step ≈ π/20。 - 切片高度过大导致遮挡:通过
sliceHeightRange做归一化,避免某一项过度突出。
- 标签与引导线(3D)
- 3D 文本与线条在某些角度可能被遮挡;可微调
endPosArr的x/y/z加上微小偏移,或降低viewControl.alpha。 - 不建议开启自由旋转:旋转后标签可能穿模或与曲面错位。
- 透明支撑环与 tooltip 冲突
- 透明
surface可能拦截事件;通过tooltip.axisPointer.type = 'none'、以及将透明环的name设为特殊值并在formatter里跳过,可规避无意义提示。
- 颜色与图例对齐
- 图例项来源于
series.name,确保与数据name一致;颜色建议集中在数据的itemStyle.color,避免后续混乱。
- 视角与后处理
postEffect.SSAO在低端设备上可能产生锯齿或性能问题;可在移动端降级关闭或降低质量级别。
- 容器尺寸变化
- 容器大小变化时需重新渲染或触发图表
resize,否则曲面比例失衡;在 React 中可监听容器尺寸变化并调用chart.resize()。
- SSR / 首屏渲染
- ECharts-GL 依赖浏览器 WebGL 环境,服务端渲染需延迟到客户端挂载后再初始化。
- 数据边界与数值格式化
- 当数据为空时,避免渲染假数据;页面上用
--做占位(见useLeftContent.tsx)。 - 金额格式统一用千分位与“元”,可复用
formatAmount方法:
const formatAmount = (n: number) => {const v = Number(n) || 0;const isInt = Number.isInteger(v);const str = (isInt ? v : v.toFixed(2)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');return `${str}元`;
};
配置项说明(精选)
ECharts-GL(本项目使用)
series.surface.parametricEquation: 参数方程,决定曲面形状。series.surface.itemStyle.color/opacity: 切片颜色与透明度。line3D/scatter3D: 标签引导线与文本,放置在三维坐标系中。grid3D.viewControl: 视角控制,如alpha旋转角、交互灵敏度。grid3D.postEffect: 后处理,含bloom高光与SSAO环境光遮蔽。
自定义(本项目扩展)
internalDiameterRatio→k:内外径比例换算为参数方程辅助参数。sliceHeightRange:映射数据到切片厚度,避免高度失衡。endPosArr算法:考虑象限与偏移,保证标签分布均衡。
2D 饼图标签(微协同页面)
label.rich:两行文本样式(名称/金额),统一字号与颜色。labelLine.length/length2:两段引导线长度;lineStyle控制颜色与宽度。
常见问题 Q&A
- 3D 表面有锯齿?
- 降低
u/v步进密度、开启postEffect.bloom并调整强度;在低端设备关闭SSAO。
- 标签被遮挡或穿模?
- 微调
endPosArr,减小alpha值,或固定视角不允许旋转。
- 性能偏慢?
- 减少数据项、降低步进密度、禁用不必要的后处理;在 React 中避免频繁重渲染。
- 想实现高亮/选择?
- 3D 下选择较难稳定,推荐在 2D 饼图实现交互逻辑;3D 仅作视觉展示。
