本文系统总结在项目中用 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 项目中便捷渲染)
安装示例:
bash
yarn add echarts echarts-gl echarts-for-react
在 React 组件中引入:
tsx
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:
ts
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的计算要考虑象限(通过中径角判断朝向),以避免文本与线段穿插扭曲:
ts
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,保留核心逻辑并做少量注释:
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;
使用流程与集成
- 准备数据
ts
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)
tsx
<Pie3DChart dataList={dataList} sliceHeightRange={[8, 20]} />
- 页面集成与 2D 标签对齐(微协同场景)
微协同页面使用 2D 饼图的 label/labelLine 控制两行标签与两段引导线长度,便于与 3D 效果保持视觉一致:
ts
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方法:
ts
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 仅作视觉展示。