背景

用 ECharts GL 做「3D 环图」:参数曲面与引导线实战

发表于 2026/05/06 15:00
🌺 摘要
从数据映射到 startRatio/endRatio,用 getParametricEquation 生成每一块扇环曲面;最大值加高、line3D + scatter3D 做标注;grid3D 控制视角与自动旋转。

大屏或专题页里,平面饼图有时不够「站得住」。把占比拆成一圈带厚度的环段,再略微俯视、自动旋转,观感会接近 3D 仪表盘。下面用 ECharts 5 + echarts-gl,把每一类数据画成一块 参数曲面(surface),再用 line3D + scatter3D 拉引导线和文字。

文末 附录 附有 完整 TypeScript 源码,便于直接复制或对照调试。

3D 环图效果示意

1. 依赖与入口

  • echarts:初始化实例、setOption
  • echarts-gl:提供 surfaceline3Dscatter3D 等三维系列。
  • 容器为普通 DOM,echarts.init(dom) 后传入 getChartOption(data) 生成的配置即可。

数据形态可以收敛成 { name, value, color? }[],先算 总值每一项占比,后面所有角度都按 0~1 的比例 映射到 0~2π

2. 把「饼图的一块」变成参数曲面

平面饼图是极角一段弧;3D 环图则在 XY 平面上仍是这段弧,但在 截面方向(可以理解为管道横截面)上不是一个实心圆,而是 外圆 + 内凹,形成环形体;再在 Z 方向做出 厚度顶面高度差,就有「立起来的扇环」。

实现上不必手工三角剖分,而是用 type: 'surface'parametric: true,交给 gl 按 uv 采样:

  • u:沿环方向走一圈(大致对应饼图扫过的角度区间)。
  • v:沿截面方向包一圈,配合系数 k 控制环的粗细/鼓包程度(代码里常取 0.2 左右)。

核心是一个 getParametricEquation(startRatio, endRatio, k, visualHeight, radiusScale, heightScale),返回:

  • u / vmin、max、step(采样密度影响性能与边缘是否锯齿)。
  • x(u,v) / y(u,v):在 startRadianendRadian 之间按 u 插值,半径里带入 (1 + cos(v) * k) * radiusScale,把圆环铺开。
  • z(u,v):一侧略低、一侧抬高到 visualHeight * heightScale,形成块的「顶」;与 u 相关的分支用来收住上下底,避免网格在接缝处乱翘。

每一块 series 对应 一段连续的 startRatioendRatio,多块拼起来就是一整个环。

3. 让「最大项」更醒目:visualHeight

除了颜色,还可以用 高度 表达主次:对 value 最大的那一项单独给一个更大的 visualHeight(例如普通块用 0.96,最大块用 1.7),其余参数方程不变。这样环上会有一块明显 「抬高」,视觉焦点更自然。

4. 引导线:line3D + scatter3D

饼图外缘的折线标注,在 3D 里可以拆成两段折线:

  1. 在扇区 中点角度 midRadian 上,取环外一点 (posX, posY, posZ)
  2. 乘一个系数(如 1.081.16)往外「推」,中间加一个 拐点 turningPosArr,再接到 文字锚点 labelPosArr
  3. type: 'line3D'data: [起点, 拐点, 终点] 画折线。

文字本身用 scatter3DsymbolSize: 0 隐藏点,只开 labelformatter 里用 rich 分别样式化「百分比」和「名称」。
midRadian 落在不同象限时,用 flag 微调左右偏移,避免多条线挤在一起。

5. 场景:grid3D 与底图

  • xAxis3D / yAxis3D / zAxis3Dshow: false 即可,只保留空间盒。
  • grid3D.boxHeight:拉高「虚拟盒子」,环不会显得压扁。
  • viewControl
    • alpha:俯视倾角;
    • distance:相机远近;
    • autoRotate: true:缓慢自转;
    • rotateSensitivity / zoomSensitivity / panSensitivity:按产品需求是否允许用户拖拽、缩放(演示大屏常关掉缩放平移,只保留旋转或全关)。
  • graphic:在画布底层叠一张 PNG 底座type: 'image'),增强「展台」感。图片若异步加载,需要在 load 后再 setOption 更新一次 graphic,并 resize,避免首帧空白。

6. 工程侧:实例初始化与自适应

  1. getChartOption:由数据生成 series(多块 surface + 若干 line3D/scatter3D),并拼上 legendgraphicgrid3D
  2. 首屏与资源:底图未加载完时先用 URL 占位,load 后换成 Image 对象并刷新 option,再 resize
  3. window.resize:监听窗口变化执行 chart.resize()

异步回调里更新 option 前务必判断 chart.isDisposed()。更细的实现见下方附录中的完整源码。

7. 可调参数小结

方向常见抓手
环粗细方程里的 kradiusScale
整体胖瘦/高度heightScaleboxHeight
主次对比各扇区的 visualHeight、颜色
观感节奏viewControl.alphadistanceautoRotate
性能u/vstep(越大越快、越糙)

8. 小结

3D 环图是 参数曲面 + 3D 引导线 的组合;搞清 startRatio / endRatiogetParametricEquation 的对应关系后,改样式与交互会直观很多。需要落地时可直接参考附录中的完整示例。


附:完整源码(TypeScript)

底图路径示例为 @/assets/images/overview/subjectBg.png,请按你的工程别名与静态资源位置调整。

import * as echarts from "echarts";
import "echarts-gl";
import bottomImgUrl from "@/assets/images/overview/subjectBg.png?url";

const bottomImage = new Image();
bottomImage.src = bottomImgUrl;
let bottomImageLoaded = false;
bottomImage.addEventListener("load", () => {
  bottomImageLoaded = true;
});
export interface ChartData {
  name: string;
  value: number;
  color?: string;
}

const getParametricEquation = (
  startRatio: number,
  endRatio: number,
  k: number,
  visualHeight: number,
  radiusScale: number,
  heightScale: number,
) => {
  const startRadian = startRatio * Math.PI * 2;
  const endRadian = endRatio * Math.PI * 2;

  k = typeof k === "number" && !isNaN(k) ? k : 1 / 3;

  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: number, v: number) {
      const radius = (1 + Math.cos(v) * k) * radiusScale;
      if (u < startRadian) {
        return Math.cos(startRadian) * radius;
      }
      if (u > endRadian) {
        return Math.cos(endRadian) * radius;
      }
      return Math.cos(u) * radius;
    },
    y(u: number, v: number) {
      const radius = (1 + Math.cos(v) * k) * radiusScale;
      if (u < startRadian) {
        return Math.sin(startRadian) * radius;
      }
      if (u > endRadian) {
        return Math.sin(endRadian) * radius;
      }
      return Math.sin(u) * radius;
    },
    z(u: number, v: number) {
      const pieHeight = visualHeight * heightScale;
      if (u < -Math.PI * 0.5) {
        return Math.sin(u) * heightScale;
      }
      if (u > Math.PI * 2.5) {
        return Math.sin(u) * pieHeight;
      }
      return Math.sin(v) > 0 ? pieHeight : -heightScale;
    },
  };
};
const addLabelLine = (
  series: unknown[],
  startRatio: number,
  endRatio: number,
  value: number,
  visualHeight: number,
  total: number,
  k: number,
  radiusScale: number,
  heightScale: number,
  i: number,
  name: string,
  color: string = "#fff"
) => {
  const midRadian = (startRatio + endRatio) * Math.PI;
  const radius = (1 + k) * radiusScale;
  const posX = Math.cos(midRadian) * radius;
  const posY = Math.sin(midRadian) * radius;
  const posZ = visualHeight * heightScale;

  const flag = (midRadian >= 0 && midRadian <= Math.PI / 2) || (midRadian >= (3 * Math.PI) / 2 && midRadian <= Math.PI * 2) ? 1 : -1;

  const turningPosArr = [
    posX * 1.08 + i * 0.08 * flag + (flag < 0 ? -0.16 : 0),
    posY * 1.08 + i * 0.08 * flag + (flag < 0 ? -0.16 : 0),
    posZ * 0.9,
  ];
  const endPosArr = [
    posX * 1.16 + i * 0.08 * flag + (flag < 0 ? -0.16 : 0),
    posY * 1.16 + i * 0.08 * flag + (flag < 0 ? -0.16 : 0),
    posZ * 1.3,
  ];
  const labelPosArr = [
    endPosArr[0] + 0.18 * flag,
    endPosArr[1],
    endPosArr[2],
  ];

  series.push(
    {
      type: "line3D",
      lineStyle: {
        color,
        width: 1.5,
        opacity: 0.9,
      },
      data: [[posX, posY, posZ], turningPosArr, endPosArr],
    },
    {
      type: "scatter3D",
      label: {
        show: true,
        distance: 0,
        position: "center",
        formatter: () => {
          const percent = ((value / total) * 100).toFixed(1);
          return `{percent|${percent}%}\n{name|${name}}`;
        },
        rich: {
          percent: {
            color: "#fff",
            fontSize: 16,
            fontWeight: 700,
            fontFamily: "SourceHanSansCN",
            lineHeight: 20,
            align: "center",
          },
          name: {
            color: "#fff",
            fontSize: 14,
            fontWeight: 400,
            fontFamily: "SourceHanSansCN",
            lineHeight: 18,
            align: "center",
          },
        },
      },
      symbolSize: 0,
      data: [
        {
          name,
          value: labelPosArr,
        },
      ],
    }
  );
};

export const getChartOption = (data: ChartData[]) => {
  const graphic = [
    {
      id: "subject-bottom-image",
      type: "image",
      left: "center",
      bottom: 50,
      silent: true,
      style: {
        image: bottomImageLoaded ? bottomImage : bottomImgUrl,
        width: 235,
        height: 147,
      },
      zlevel: 1,
      z: 1,
    },
  ];

  const total = data.reduce((a, b) => a + b.value, 0);
  const maxValue = Math.max(...data.map((item) => item.value), 0);
  let sumValue = 0;
  const k = 0.2;
  const radiusScale = 0.9;
  const heightScale = 0.92;
  const normalVisualHeight = 0.96;
  const maxVisualHeight = 1.7;

  const series = data.map((item) => {
    const startRatio = sumValue / total;
    sumValue += item.value;
    const endRatio = sumValue / total;
    const isMaxItem = item.value === maxValue;
    const visualHeight = isMaxItem ? maxVisualHeight : normalVisualHeight;

    return {
      name: item.name,
      type: "surface",
      itemStyle: {
        color: item.color || "#D13DF2",
      },
      wireframe: {
        show: false,
      },
      pieData: item,
      pieStatus: {
        k,
        startRatio,
        endRatio,
        value: item.value,
        visualHeight,
      },
      parametric: true,
      parametricEquation: getParametricEquation(
        startRatio,
        endRatio,
        k,
        visualHeight,
        radiusScale,
        heightScale,
      ),
    };
  });
  series.forEach((item, index) => {
    const { name, itemStyle: { color }, pieStatus: { startRatio, endRatio, value, visualHeight } } = item;
    addLabelLine(series, startRatio, endRatio, value, visualHeight, total, k, radiusScale, heightScale, index, name, color);
  });
  return {
    tooltip: {
      show: false,
      backgroundColor: "transparent",
      formatter: (params: any) => {
        if (!params || params.seriesName === "mouseoutSeries") return "";
        const target = data[params.seriesIndex];
        if (!target) return "";

        // 计算百分比
        const percent = ((target.value / total) * 100).toFixed(1);

        return `
      <div style="font-size:16px;font-weight:700;text-align:center;color:#fff;font-family:'SourceHanSansCN';">${percent}%</div>
      <div style="font-size:14px;margin-top:3px;color:#fff;font-family:'SourceHanSansCN';font-weight:400;">
        ${target.name}
      </div>
    `;
      },
    },
    legend: {
      bottom: 0,
      data: data.map((item) => item.name),
      orient: "horizontal",
      type: "scroll",
      textStyle: {
        color: "#fff",
        fontSize: 12,
      },
      pageIconColor: "#999",
      pageIconInactiveColor: "#ccc",
      pageTextStyle: {
        color: "#fff",
      },
    },
    graphic,
    xAxis3D: {
      min: -1,
      max: 1,
      show: false,
    },
    yAxis3D: {
      min: -1,
      max: 1,
      show: false,
    },
    zAxis3D: {
      min: -1,
      max: 2,
      show: false,
    },
    grid3D: {
      show: false,
      boxHeight: 16,
      top: -20,
      viewControl: {
        alpha: 36,
        distance: 180,
        rotateSensitivity: 1,
        zoomSensitivity: 0,
        panSensitivity: 0,
        autoRotate: true,
        center: [0, -0.1, 0],
      },
    },
    series,
  };
};

export const mount3DRingChart = (dom: HTMLElement, data: ChartData[]) => {
  const chart = echarts.init(dom);
  const option = getChartOption(data);
  chart.setOption(option);

  if (!bottomImageLoaded) {
    bottomImage.addEventListener(
      "load",
      () => {
        if (chart.isDisposed()) return;
        chart.setOption({
          graphic: [
            {
              id: "subject-bottom-image",
              style: {
                image: bottomImage,
              },
            },
          ],
        });
        chart.resize();
      },
      { once: true },
    );
  }

  window.addEventListener("resize", () => {
    chart.resize();
  });

  return chart;
};
文章发表于 2026/05/06 15:00
上一篇 window.matchMedia:在 JavaScript 里读写媒体查询
下一篇 AI 时代,还要不要做个人博客?

评论

加载中...