一、效果概覽
本文基于 Vue 3 和 ECharts GL,實現了一個具有以下特性的 3D 餅圖:
- 立體視覺效果:通過參數方程構建 3D 扇形與底座
- 動態交互:支持點擊選中(位移效果)和懸停高亮(放大效果)
- 混合渲染:結合 3D 曲面與 2D 餅圖標簽
- 風格化設計:暗色背景搭配網格紋理,增強科技感
二、核心技術實現
1. 環境準備
import { ref, onMounted } from "vue";
import * as echarts from "echarts";
import "echarts-gl"; // 引入 3D 擴展
2. 參數方程生成器
核心函數 getParametricEquation
通過數學公式動態構建 3D 曲面:
function getParametricEquation(startRatio, endRatio, isSelected, isHovered, k, h) {// 計算弧度范圍const startRadian = startRatio * Math.PI * 2;const endRadian = endRatio * Math.PI * 2;// 動態參數控制const offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;const hoverRate = isHovered ? 1.5 : 1;return {u: { min: -Math.PI, max: Math.PI * 3 },v: { min: 0, max: Math.PI * 2 },x: (u, v) => offsetX + Math.cos(u) * (1 + Math.cos(v)*k) * hoverRate,y: (u, v) => offsetY + Math.sin(u) * (1 + Math.cos(v)*k) * hoverRate,z: (u, v) => (Math.sin(v) > 0 ? h*0.1 : -1)};
}
- u/v:定義曲面參數范圍
- hoverRate:懸停時放大系數(1.5倍)
- offsetX/Y:選中時的位移偏移
3. 復合圖表配置
通過 getPie3D
生成多層結構:
function getPie3D(pieData, internalDiameterRatio) {const series = [];// 生成數據扇形pieData.forEach(item => {series.push({type: "surface",parametric: true,itemStyle: { color: item.itemStyle.color },parametricEquation: getParametricEquation(...)});});// 添加紅色底座series.push({parametricEquation: {x: (u, v) => Math.sin(v)*0.6*Math.sin(u),z: () => Math.cos(v) > 0 ? 0.8 : -0.2},itemStyle: { color: "#2c68ac" }});// 透明支撐環(用于鼠標事件)series.push({itemStyle: { opacity: 0 },parametricEquation: {...}});return { series, grid3D: {...}, tooltip: {...} };
}
- 底座設計:通過兩個紅色圓柱增強立體層次感
- 透明環:解決 3D 曲面鼠標事件穿透問題
4. 交互事件處理
// 點擊選中
myChart.on("click", (params) => {const target = option.series[params.seriesIndex];target.parametricEquation = getParametricEquation(..., true); // 觸發位移
});// 懸停高亮
myChart.on("mouseover", (params) => {option.series[params.seriesIndex].parametricEquation = getParametricEquation(..., hoverBarHeight); // 修改高度
});// 全局恢復
myChart.on("globalout", () => {series.forEach(item => item.parametricEquation.z = defaultBarHeight);
});
三、樣式優化技巧
1. 背景網格
.chart-container::before {background-image: linear-gradient(#0e2a47 1px, transparent 1px),linear-gradient(90deg, #0e2a47 1px, transparent 1px);background-size: 20px 20px;
}
2. 標簽融合
{type: "pie",label: {formatter: "{b}\n{@percent}%",position: "outside",opacity: 0 // 通過 2D 餅圖實現標簽},itemStyle: { opacity: 0 } // 隱藏 2D 圖形
}
四、最佳實踐建議
-
性能優化:
- 調整
u/v.step
值平衡渲染質量與性能 - 禁用非必要特效(如 postEffect)
- 調整
-
擴展方向:
- 增加
autoRotate
實現自動旋轉 - 結合
dataset
實現動態數據更新
- 增加
-
調試技巧:
- 臨時設置
wireframe: { show: true }
觀察曲面結構 - 使用
viewControl
調整初始視角
- 臨時設置
五、完整代碼
<template><div class="chart-container"><div ref="chartRef" class="chart"></div></div>
</template><script setup>
import { ref, onMounted } from "vue";
import * as echarts from "echarts";
import "echarts-gl";const chartRef = ref(null);
// 默認柱狀圖高度
const defaultBarHeight = 30;
// 鼠標滑過高度
const hoverBarHeight = 40;onMounted(() => {const chartDom = chartRef.value;const myChart = echarts.init(chartDom);/*** 生成3D扇形的曲面參數方程* @param {number} startRatio - 起始比例 (0~1)* @param {number} endRatio - 結束比例 (0~1)* @param {boolean} isSelected - 是否選中狀態* @param {boolean} isHovered - 是否懸停狀態* @param {number} k - 輔助參數,控制扇形厚度* @param {number} h - 柱狀圖高度* @returns {Object} 曲面參數方程,包含u/v范圍和x/y/z坐標函數*/function getParametricEquation(startRatio,endRatio,isSelected,isHovered,k,h) {// 計算中間比例和弧度值// 將比例(0~1)轉換為弧度值(0~2π),用于三角函數計算let midRatio = (startRatio + endRatio) / 2;let startRadian = startRatio * Math.PI * 2; // 起始弧度let endRadian = endRatio * Math.PI * 2; // 結束弧度let midRadian = midRatio * Math.PI * 2; // 中間弧度// 如果只有一個扇形,則不實現選中效果。if (startRatio === 0 && endRatio === 1) {isSelected = false;}// 通過扇形內徑/外徑的值,換算出輔助參數 k(默認值 1/3)k = typeof k !== "undefined" ? k : 1 / 3;// 計算選中效果分別在 x/y 軸方向上的位移// 使用三角函數計算位移方向,0.1為位移幅度系數// 未選中狀態位移為0,選中狀態根據中間弧度計算位移方向let offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;let offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0;// 計算高亮效果的放大比例// hoverRate=0.5表示懸停時放大50%,通過參數方程中的乘法實現let hoverRate = 0.5;// 返回曲面參數方程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參數計算曲面x坐標// 公式分解:// 1. Math.cos(u) - 基礎圓形路徑// 2. (1 + Math.cos(v) * k) - 控制扇形厚度// 3. hoverRate - 懸停放大系數// 4. offsetX - 選中位移x: function (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: function (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: function (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) + 1;},};}/*** 生成3D餅圖的完整配置項* @param {Array} pieData - 餅圖數據數組* @param {number} internalDiameterRatio - 內徑/外徑比例* @returns {Object} ECharts配置項,包含series和legend等*/function getPie3D(pieData, internalDiameterRatio) {let series = [];let sumValue = 0;let startValue = 0;let endValue = 0;let legendData = [];let k =typeof internalDiameterRatio !== "undefined"? (1 - internalDiameterRatio) / (1 + internalDiameterRatio): 1 / 3;// 為每一個餅圖數據,生成一個 series-surface 配置for (let i = 0; i < pieData.length; i++) {sumValue += pieData[i].value;let seriesItem = {name:typeof pieData[i].name === "undefined"? `series${i}`: pieData[i].name,type: "surface",parametric: true,wireframe: {show: false,},pieData: pieData[i],pieStatus: {selected: false,hovered: false,k: k,},};if (typeof pieData[i].itemStyle != "undefined") {let itemStyle = {};typeof pieData[i].itemStyle.color != "undefined"? (itemStyle.color = pieData[i].itemStyle.color): null;typeof pieData[i].itemStyle.opacity != "undefined"? (itemStyle.opacity = pieData[i].itemStyle.opacity): null;seriesItem.itemStyle = itemStyle;}series.push(seriesItem);}// 使用上一次遍歷時,計算出的數據和 sumValue,調用 getParametricEquation 函數,// 向每個 series-surface 傳入不同的參數方程 series-surface.parametricEquation,也就是實現每一個扇形。for (let i = 0; i < series.length; i++) {endValue = startValue + series[i].pieData.value;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,defaultBarHeight);startValue = endValue;legendData.push(series[i].name);}// 添加兩個紅色圓柱底座series.push({name: "base1",type: "surface",parametric: true,silent: true,wireframe: {show: false,},itemStyle: {color: "#2c68ac",opacity: 1},parametricEquation: {u: {min: 0,max: Math.PI * 2,step: Math.PI / 40,},v: {min: 0,max: Math.PI,step: Math.PI / 40,},x: function (u, v) {return Math.sin(v) * 0.6 * Math.sin(u) + Math.sin(u) * 0.6;},y: function (u, v) {return Math.sin(v) * 0.6 * Math.cos(u) + Math.cos(u) * 0.6;},z: function (u, v) {return Math.cos(v) > 0 ? 0.8 : -0.2;},},});series.push({name: "base2",type: "surface",parametric: true,silent: true,wireframe: {show: false,},itemStyle: {color: "#1b4475",opacity: 1},parametricEquation: {u: {min: 0,max: Math.PI * 2,step: Math.PI / 40,},v: {min: 0,max: Math.PI,step: Math.PI / 40,},x: function (u, v) {return Math.sin(v) * 0.7 * Math.sin(u) + Math.sin(u) * 0.7;},y: function (u, v) {return Math.sin(v) * 0.7 * Math.cos(u) + Math.cos(u) * 0.7;},z: function (u, v) {return -1;},},});// 補充一個透明的圓環,用于支撐高亮功能的近似實現。series.push({name: "mouseoutSeries",type: "surface",parametric: true,wireframe: {show: false,},itemStyle: {opacity: 0,},parametricEquation: {u: {min: 0,max: Math.PI * 2,step: Math.PI / 20,},v: {min: 0,max: Math.PI,step: Math.PI / 20,},x: function (u, v) {return Math.sin(v) * Math.sin(u) + Math.sin(u);},y: function (u, v) {return Math.sin(v) * Math.cos(u) + Math.cos(u);},z: function (u, v) {return Math.cos(v) > 0 ? 0.1 : -0.1;},},});// 準備待返回的配置項,把準備好的 legendData、series 傳入。let option = {//animation: false,legend: {data: legendData,orient: "vertical",right: "5%",top: "center",itemGap: 20,textStyle: {color: "#fff",fontSize: 14,// fontWeight: 'bold', // 增加字體加粗},},tooltip: {formatter: (params) => {if (params.seriesName !== "mouseoutSeries") {const value =option.series[params.seriesIndex]?.pieData?.value || "";return `${params.seriesName}<br/><span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${params.color};"></span>${value}`;}},},xAxis3D: {min: -1,max: 1,},yAxis3D: {min: -1,max: 1,},zAxis3D: {min: -1,max: 1,},grid3D: {show: false,boxHeight: 10,viewControl: {alpha: 45,distance: 250,rotateSensitivity: 0,zoomSensitivity: 0,panSensitivity: 0,autoRotate: false,},},series: series,};return option;}let data = [{value: 60,name: "通過",itemStyle: { color: "#82C3FF" },},{value: 6,name: "不通過",itemStyle: { color: "#FFB042" },},{value: 18,name: "待審核",itemStyle: { color: "#61D6E2" },},];// 傳入數據生成 optionlet option = getPie3D(data, 0);// 監聽鼠標事件,實現餅圖選中效果(單選),近似實現高亮(放大)效果。let selectedIndex = "";let hoveredIndex = "";// 監聽點擊事件,實現選中效果(單選)// 原理:通過修改參數方程中的offsetX/Y實現扇形位移效果myChart.on("click", function (params) {// 目標對象const target = option.series[params.seriesIndex] || {};// 從 option.series 中讀取重新渲染扇形所需的參數,將是否選中取反。let isSelected = !target?.pieStatus?.selected;let isHovered = target?.pieStatus?.hovered;let k = target?.pieStatus?.k;let startRatio = target?.pieData?.startRatio;let endRatio = target?.pieData?.endRatio;const pieData = option.series[selectedIndex]?.pieData || {};// 如果之前選中過其他扇形,將其取消選中(對 option 更新)if (selectedIndex !== "" && selectedIndex !== params.seriesIndex) {option.series[selectedIndex].parametricEquation = getParametricEquation(pieData.startRatio,pieData.endRatio,false,false,k,defaultBarHeight);option.series[selectedIndex].pieStatus.selected = false;}// 對當前點擊的扇形,執行選中/取消選中操作(對 option 更新)option.series[params.seriesIndex].parametricEquation =getParametricEquation(startRatio,endRatio,isSelected,isHovered,k,defaultBarHeight);option.series[params.seriesIndex].pieStatus.selected = isSelected;// 如果本次是選中操作,記錄上次選中的扇形對應的系列號 seriesIndexisSelected ? (selectedIndex = params.seriesIndex) : null;// 使用更新后的 option,渲染圖表myChart.setOption(option);});// 監聽 mouseover,近似實現高亮(放大)效果// 原理:通過修改參數方程中的hoverRate實現放大效果myChart.on("mouseover", function (params) {// 準備重新渲染扇形所需的參數let isSelected;let startRatio;let endRatio;let k;let isHoveredNew = false;// 如果觸發 mouseover 的扇形當前已高亮,則不做操作if (hoveredIndex === params.seriesIndex) {return;// 否則進行高亮及必要的取消高亮操作} else {// 如果當前有高亮的扇形,取消其高亮狀態(對 option 更新)if (hoveredIndex !== "") {const hoverTarget = option.series[hoveredIndex] || {};// 從 option.series 中讀取重新渲染扇形所需的參數,將是否高亮設置為 false。isSelected = hoverTarget?.pieStatus?.selected;isHoveredNew = false;startRatio = hoverTarget?.pieData?.startRatio;endRatio = hoverTarget?.pieData?.endRatio;k = hoverTarget?.pieStatus.k;// 對當前點擊的扇形,執行取消高亮操作(對 option 更新)option.series[hoveredIndex].parametricEquation = getParametricEquation(startRatio,endRatio,isSelected,isHoveredNew,k,defaultBarHeight);option.series[hoveredIndex].pieStatus.hovered = isHoveredNew;// 將此前記錄的上次選中的扇形對應的系列號 seriesIndex 清空hoveredIndex = "";}// 如果觸發 mouseover 的扇形不是透明圓環,將其高亮(對 option 更新)if (params.seriesName !== "mouseoutSeries") {const seriesSeries = option.series[params.seriesIndex] || {};// 從 option.series 中讀取重新渲染扇形所需的參數,將是否高亮設置為 true。isSelected = seriesSeries?.pieStatus?.selected;isHoveredNew = true;startRatio = seriesSeries?.pieData?.startRatio;endRatio = seriesSeries?.pieData?.endRatio;k = seriesSeries?.pieStatus?.k;// 對當前點擊的扇形,執行高亮操作(對 option 更新)option.series[params.seriesIndex].parametricEquation =getParametricEquation(startRatio,endRatio,isSelected,isHoveredNew,k,hoverBarHeight);if (option.series[params.seriesIndex]?.pieStatus) {option.series[params.seriesIndex].pieStatus.hovered = isHoveredNew;} else {option.series[params.seriesIndex].pieStatus = {hovered: isHoveredNew,};}// 記錄上次高亮的扇形對應的系列號 seriesIndexhoveredIndex = params.seriesIndex;}// 使用更新后的 option,渲染圖表myChart.setOption(option);}});// 修正取消高亮失敗的 bugmyChart.on("globalout", function () {let isHoveredNew = false;let k;if (hoveredIndex !== "") {const curSeries = option.series[hoveredIndex] || {};// 從 option.series 中讀取重新渲染扇形所需的參數,將是否高亮設置為 true。let isSelected = curSeries.pieStatus?.selected;k = curSeries?.pieStatus?.k;let startRatio = curSeries?.pieData?.startRatio;let endRatio = curSeries?.pieData?.endRatio;// 對當前點擊的扇形,執行取消高亮操作(對 option 更新)option.series[hoveredIndex].parametricEquation = getParametricEquation(startRatio,endRatio,isSelected,isHoveredNew,k,defaultBarHeight);option.series[hoveredIndex].pieStatus.hovered = isHoveredNew;// 將此前記錄的上次選中的扇形對應的系列號 seriesIndex 清空hoveredIndex = "";}// 使用更新后的 option,渲染圖表myChart.setOption(option);});option.series.push({name: "pie2d",type: "pie",labelLine: {length: 40,length2: 120,lineStyle: {width: 2,},},label: {opacity: 1,show: true,position: "outside",fontSize: 16,itemStyle: {color: "#fff",fontSize: 14,fontWeight: "bold",fontFamily: "Arial, sans-serif",},textStyle: {color: "#fff",lineHeight: 30,rich: {top: {verticalAlign: "middle",padding: [0, 0, 0, 0],},bottom: {verticalAlign: "middle",padding: [0, 0, 0, 0],},},},formatter: (params) => {return `${params.name}\n${params.percent}%`;},},startAngle: -66, //起始角度,支持范圍[0, 360]。clockwise: false, //餅圖的扇區是否是順時針排布。上述這兩項配置主要是為了對齊3d的樣式radius: ["40%", "36%"],// center: ['55%', '48%'], //指示線的位置data: data,itemStyle: {opacity: 0,},});myChart.setOption(option);// 組件卸載時清除事件監聽return () => {window.removeEventListener("resize", resizeChart);myChart.dispose();};
});
</script><style scoped>
.chart-container {width: 100%;height: 100vh;background-color: #001529;display: flex;justify-content: center;align-items: center;position: relative;
}.chart {width: 800px;height: 600px;
}/* 添加網格背景 */
.chart-container::before {content: "";position: absolute;top: 0;left: 0;width: 100%;height: 100%;background-image: linear-gradient(#0e2a47 1px, transparent 1px),linear-gradient(90deg, #0e2a47 1px, transparent 1px);background-size: 20px 20px;opacity: 0.3;z-index: 0;
}.chart {z-index: 1;
}
</style>
通過本文方案,開發者可快速構建具有強交互性的 3D 數據可視化組件。關鍵點在于參數方程的靈活運用與事件系統的深度集成,這種模式可擴展至其他 3D 圖表類型(如柱狀圖、散點圖)的開發。