使用 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 项目中便捷渲染)

安装示例:

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


实现步骤(核心流程)

  1. 计算比例与厚度
  • 累加数据得到 sumValue,为每个数据计算 startRatio / endRatio
  • 取数据最大值作为基准,按比例将值映射为高度 h
ts 复制代码
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 的计算要考虑象限(通过中径角判断朝向),以避免文本与线段穿插扭曲:
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,
];
  1. 透明"支撑环"
  • 额外添加一个透明 surface,用于近似实现高亮/鼠标交互的承载,避免直接与扇形交互造成干扰。
  1. 场景配置
  • grid3D 控制视角与后处理:开启 postEffect.bloomSSAO 增强质感,同时合理设置 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;

使用流程与集成

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

注意事项与踩坑实录

  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 方法:
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 环境光遮蔽。

自定义(本项目扩展)

  • 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 仅作视觉展示。

相关推荐
学無芷境2 小时前
Large-Scale 3D Medical Image Pre-training with Geometric Context Priors
人工智能·3d
暴风鱼划水3 小时前
三维重建【4-A】3D Gaussian Splatting:代码解读
python·深度学习·3d·3dgs
形宙数字8 小时前
【形宙数字】MANGOLD INTERACT 行为观察分析系统-行为观察统计分析-人类行为学研究-行为逻辑
信息可视化·数据分析·行为观察分析系统·行为观察统计分析·人类行为学研究·行为逻辑·形宙数字
TG:@yunlaoda360 云老大9 小时前
火山引擎数智平台VeDI重磅发布“AI助手”:以大模型驱动数据飞轮,赋能非技术人员高效“看数、用数”
人工智能·信息可视化·火山引擎
景早10 小时前
小黑记账清单案例(axios,echarts,vue)
前端·vue.js·echarts
老黄编程11 小时前
pcl 3DSC特征描述符、对应关系可视化以及ICP配准
3d·pcl·3dsc·icp
猿来是你_L15 小时前
UGUI笔记——3D坐标转换成UGUI坐标
笔记·3d
uuai1 天前
echarts不同版本显示不一致问题
前端·javascript·echarts
Doc.S1 天前
【保姆级教程】在AutoDL容器中部署EGO-Planner,实现无人机动态避障规划
人工智能·python·信息可视化·机器人