当前位置: 首页 > news >正文

使用 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),并控制边界(区间外使用边缘角度),从而形成饼图扇形段的立体曲面。


实现步骤(核心流程)

  1. 计算比例与厚度
  • 累加数据得到 sumValue,为每个数据计算 startRatio / endRatio
  • 取数据最大值作为基准,按比例将值映射为高度 h
const heightFromVal = (v: number) => {if (!maxValue || maxValue <= 0) return minH;const ratio = v / maxValue;return minH + (maxH - minH) * ratio;
};
  1. 为每个数据构造 series.surface
  • type: 'surface'parametric: true,设置 parametricEquation 为曲面方程。
  • 将颜色与透明度通过 itemStyle.color / itemStyle.opacity 传入(与 3D 视觉保持一致)。
  1. 标签与引导线(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,
];
  1. 透明“支撑环”
  • 额外添加一个透明 surface,用于近似实现高亮/鼠标交互的承载,避免直接与扇形交互造成干扰。
  1. 场景配置
  • grid3D 控制视角与后处理:开启 postEffect.bloomSSAO 增强质感,同时合理设置 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;

使用流程与集成

  1. 准备数据
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' } },
];
  1. 组件调用(React)
<Pie3DChart dataList={dataList} sliceHeightRange={[8, 20]} />
  1. 页面集成与 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 } },

注意事项与踩坑实录

  1. 性能与细节平衡
  • 参数方程的步进值(u.step, v.step)越小,曲面越精细但性能越差;在业务场景下推荐 u.step ≈ π/32, v.step ≈ π/20
  • 切片高度过大导致遮挡:通过 sliceHeightRange 做归一化,避免某一项过度突出。
  1. 标签与引导线(3D)
  • 3D 文本与线条在某些角度可能被遮挡;可微调 endPosArrx/y/z 加上微小偏移,或降低 viewControl.alpha
  • 不建议开启自由旋转:旋转后标签可能穿模或与曲面错位。
  1. 透明支撑环与 tooltip 冲突
  • 透明 surface 可能拦截事件;通过 tooltip.axisPointer.type = 'none'、以及将透明环的 name 设为特殊值并在 formatter 里跳过,可规避无意义提示。
  1. 颜色与图例对齐
  • 图例项来源于 series.name,确保与数据 name 一致;颜色建议集中在数据的 itemStyle.color,避免后续混乱。
  1. 视角与后处理
  • postEffect.SSAO 在低端设备上可能产生锯齿或性能问题;可在移动端降级关闭或降低质量级别。
  1. 容器尺寸变化
  • 容器大小变化时需重新渲染或触发图表 resize,否则曲面比例失衡;在 React 中可监听容器尺寸变化并调用 chart.resize()
  1. SSR / 首屏渲染
  • ECharts-GL 依赖浏览器 WebGL 环境,服务端渲染需延迟到客户端挂载后再初始化。
  1. 数据边界与数值格式化
  • 当数据为空时,避免渲染假数据;页面上用 -- 做占位(见 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 环境光遮蔽。

自定义(本项目扩展)

  • internalDiameterRatiok:内外径比例换算为参数方程辅助参数。
  • sliceHeightRange:映射数据到切片厚度,避免高度失衡。
  • endPosArr 算法:考虑象限与偏移,保证标签分布均衡。

2D 饼图标签(微协同页面)

  • label.rich:两行文本样式(名称/金额),统一字号与颜色。
  • labelLine.length/length2:两段引导线长度;lineStyle 控制颜色与宽度。

常见问题 Q&A

  1. 3D 表面有锯齿?
  • 降低 u/v 步进密度、开启 postEffect.bloom 并调整强度;在低端设备关闭 SSAO
  1. 标签被遮挡或穿模?
  • 微调 endPosArr,减小 alpha 值,或固定视角不允许旋转。
  1. 性能偏慢?
  • 减少数据项、降低步进密度、禁用不必要的后处理;在 React 中避免频繁重渲染。
  1. 想实现高亮/选择?
  • 3D 下选择较难稳定,推荐在 2D 饼图实现交互逻辑;3D 仅作视觉展示。

http://www.dtcms.com/a/574214.html

相关文章:

  • 做电影网站视频放在那里南阳做那个网站好
  • 美德的网站建设局网站建设招标
  • 学校网站的建设论文怎么建网站做推广
  • 第四阶段通讯开发-7:TCPListener和TCPClient
  • 中国最权威的网站排名电脑网站安全证书有问题如何解决
  • 网站建设实训小结在线网站流量查询
  • 深圳网站建设自己人做1688网站到哪里找图片
  • C++ —— list
  • xv6 附录A
  • 【设计题】如何实现一个线程安全的缓存?
  • 网站透明效果wordpress广告插件中文
  • 网站建设费用进会计什么科目界面设计与制作是做什么的
  • 中小企业网站建设如何c 网站开发教程
  • 深度学习-池化层
  • ruoyi-app学习路线
  • 网站群建设意见网站建设+廊坊
  • 数据库关系模式核心概念详解:候选关键字与无损连接判断
  • 做外贸上哪些网站找客户网页设计收费标准
  • 阿里云ALB可编程脚本示例
  • wordpress网站非常慢网站备案协议书
  • Nginx防御HTTP Host头注入漏洞:实战配置漏洞修复教程
  • 南宁手机网站制作公司软件工程学什么课程
  • HTML - 换行标签的 3 种写法(<br>、<br/>、<br />)
  • 做电影网站需要的服务器配置wordpress程序伪静态
  • 是网站建设专业好做如美团式网站要多少钱
  • RPA概念是什么?和AI有哪些区别?
  • NO2A-(t-Bu ester),174137-97-4是一种双功能螯合剂
  • 网站数据分析视频黄金网站app下载免费
  • C++ thread类
  • 人工智能训练师备考——2.1.2题解