从数据映射到 startRatio/endRatio,用 getParametricEquation 生成每一块扇环曲面;最大值加高、line3D + scatter3D 做标注;grid3D 控制视角与自动旋转。
大屏或专题页里,平面饼图有时不够「站得住」。把占比拆成一圈带厚度的环段,再略微俯视、自动旋转,观感会接近 3D 仪表盘。下面用 ECharts 5 + echarts-gl,把每一类数据画成一块 参数曲面(surface),再用 line3D + scatter3D 拉引导线和文字。
文末 附录 附有 完整 TypeScript 源码,便于直接复制或对照调试。

1. 依赖与入口
- echarts:初始化实例、
setOption。 - echarts-gl:提供
surface、line3D、scatter3D等三维系列。 - 容器为普通 DOM,
echarts.init(dom)后传入getChartOption(data)生成的配置即可。
数据形态可以收敛成 { name, value, color? }[],先算 总值 与 每一项占比,后面所有角度都按 0~1 的比例 映射到 0~2π。
2. 把「饼图的一块」变成参数曲面
平面饼图是极角一段弧;3D 环图则在 XY 平面上仍是这段弧,但在 截面方向(可以理解为管道横截面)上不是一个实心圆,而是 外圆 + 内凹,形成环形体;再在 Z 方向做出 厚度 和 顶面高度差,就有「立起来的扇环」。
实现上不必手工三角剖分,而是用 type: 'surface' 且 parametric: true,交给 gl 按 u、v 采样:
u:沿环方向走一圈(大致对应饼图扫过的角度区间)。v:沿截面方向包一圈,配合系数k控制环的粗细/鼓包程度(代码里常取0.2左右)。
核心是一个 getParametricEquation(startRatio, endRatio, k, visualHeight, radiusScale, heightScale),返回:
u/v的 min、max、step(采样密度影响性能与边缘是否锯齿)。x(u,v)/y(u,v):在startRadian~endRadian之间按u插值,半径里带入(1 + cos(v) * k) * radiusScale,把圆环铺开。z(u,v):一侧略低、一侧抬高到visualHeight * heightScale,形成块的「顶」;与u相关的分支用来收住上下底,避免网格在接缝处乱翘。
每一块 series 对应 一段连续的 startRatio~endRatio,多块拼起来就是一整个环。
3. 让「最大项」更醒目:visualHeight
除了颜色,还可以用 高度 表达主次:对 value 最大的那一项单独给一个更大的 visualHeight(例如普通块用 0.96,最大块用 1.7),其余参数方程不变。这样环上会有一块明显 「抬高」,视觉焦点更自然。
4. 引导线:line3D + scatter3D
饼图外缘的折线标注,在 3D 里可以拆成两段折线:
- 在扇区 中点角度
midRadian上,取环外一点(posX, posY, posZ)。 - 乘一个系数(如
1.08、1.16)往外「推」,中间加一个 拐点turningPosArr,再接到 文字锚点labelPosArr。 - 用
type: 'line3D',data: [起点, 拐点, 终点]画折线。
文字本身用 scatter3D:symbolSize: 0 隐藏点,只开 label,formatter 里用 rich 分别样式化「百分比」和「名称」。
midRadian 落在不同象限时,用 flag 微调左右偏移,避免多条线挤在一起。
5. 场景:grid3D 与底图
xAxis3D/yAxis3D/zAxis3D:show: false即可,只保留空间盒。grid3D.boxHeight:拉高「虚拟盒子」,环不会显得压扁。viewControl:alpha:俯视倾角;distance:相机远近;autoRotate: true:缓慢自转;rotateSensitivity/zoomSensitivity/panSensitivity:按产品需求是否允许用户拖拽、缩放(演示大屏常关掉缩放平移,只保留旋转或全关)。
graphic:在画布底层叠一张 PNG 底座(type: 'image'),增强「展台」感。图片若异步加载,需要在load后再setOption更新一次graphic,并resize,避免首帧空白。
6. 工程侧:实例初始化与自适应
getChartOption:由数据生成series(多块surface+ 若干line3D/scatter3D),并拼上legend、graphic、grid3D。- 首屏与资源:底图未加载完时先用 URL 占位,
load后换成Image对象并刷新 option,再resize。 window.resize:监听窗口变化执行chart.resize()。
异步回调里更新 option 前务必判断 chart.isDisposed()。更细的实现见下方附录中的完整源码。
7. 可调参数小结
| 方向 | 常见抓手 |
|---|---|
| 环粗细 | 方程里的 k、radiusScale |
| 整体胖瘦/高度 | heightScale、boxHeight |
| 主次对比 | 各扇区的 visualHeight、颜色 |
| 观感节奏 | viewControl.alpha、distance、autoRotate |
| 性能 | u/v 的 step(越大越快、越糙) |
8. 小结
3D 环图是 参数曲面 + 3D 引导线 的组合;搞清 startRatio / endRatio 与 getParametricEquation 的对应关系后,改样式与交互会直观很多。需要落地时可直接参考附录中的完整示例。
附:完整源码(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;
};
评论
加载中...