3D玫瑰图

最近项目上遇到一个需求,绘制出3D玫瑰图的效果。

先放一张效果图

去网上找了一大圈,全部是3D饼环图的案例。那就只能自己手撸出来了。

首先,我们需要搞懂曲面参数方程

红色圆的参数方程

js 复制代码
x: cosA  
y: sinA

为了能看到这个用参数曲面绘制的圆,给其增加加厚度(变成圆柱)

js 复制代码
z: sinB > 0 ? h : -h

再将圆上每一个点,都变换成一个以该点为圆心的新圆

绿色部分的参数方程

js 复制代码
x: cosA * (1 + k * cosB) 
y: sinA * (1 + k * sinB)  
z: h * sinB 

将绿色部分拍扁,得到饼环

黄色部分的参数方程

js 复制代码
x: cosA * (1 + k * cosB)   // k 为内径 / 外径
y: sinA * (1 + k * sinB)  
z:  sinB > 0 ? h : -h // h为高度

参数方程

js 复制代码
    k = 0.3
    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 Math.cos(startRadian) * (1 + Math.cos(v)* k) * hoverRate;
        }
        if (u > endRadian) {
            return Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate;
        }
        return Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate;
    },

    y(u, v) {
        if (u < startRadian) {
            return Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate ;
        }
        if (u > endRadian) {
            return Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate ;
        }
        return 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;
        }
        // 当前图形的高度是Z根据h(每个value的值决定的)
        return Math.sin(v) > 0 ? 1 * h * 0.1 : -h;
    },

当k是变量时,扇形的面积就会缩放

但是缩放的中心线还是上面提过的红线圆。 这个时候我们就可以想到,只要把红线内的扇形给截掉,那么剩下的就是玫瑰图了(也叫南丁格尔图),

参数方程

js 复制代码
x(u, v) {
    if (u < startRadian) {
        return Math.cos(startRadian) * (0.6+ (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate;
    }
    if (u > endRadian) {
        return Math.cos(endRadian) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate;
    }
    return Math.cos(u) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate;
},

y(u, v) {
    if (u < startRadian) {
        return Math.sin(startRadian) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate ;
    }
    if (u > endRadian) {
        return Math.sin(endRadian) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate ;
    }
    return Math.sin(u) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate ;
},

全部代码

js 复制代码
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<style>
body {
font-size: 68px;
color:#fff;
margin:0;padding:0;
text-align: center;
height:500px;
}
</style>
<title>pie3DChart</title>
<!-- 引入 echarts.js -->
<script src="echarts.min.js"></script>
<script src="echarts-gl.min.js"></script>
</head>

<body>
<!-- 为ECharts准备一个具备大小(宽高)的Dom -->
<div id="main" style="width:100%;height:100%;"></div>
<script type="text/javascript">
var chartDom = document.getElementById('main');
var myChart = echarts.init(chartDom);
let selectedIndex = '';
let hoveredIndex = '';
var option = getPie3D(
[
{
                name: 'aa',
                value: 57,
                itemStyle: {
                                color: '#A1C9FF',
                },
},
{
                name: 'bb',
                value: 16 ,
                itemStyle: {
                                color: '#4AE4DF',
                },
},
{
                name: 'cc',
                value: 23,
                itemStyle: {
                                color: '#2D9AFF',
                },
},
{
                name: 'dd',
                value: 32,
                itemStyle: {
                                color: '#4CCCFF',
                },
},
{
                name: 'ee',
                value: 44,
                itemStyle: {
                                color: '#7B95FF',
                },
},
],
0.2
);
// 生成扇形的曲面参数方程
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) {
// eslint-disable-next-line no-param-reassign
isSelected = false;
}

// 计算高亮效果的放大比例(未高亮,则比例为 1)
const hoverRate = isHovered ? 1.1 : 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 Math.cos(startRadian) * (0.6+ (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate;
                }
                if (u > endRadian) {
                                return Math.cos(endRadian) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate;
                }
                return Math.cos(u) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate;
},

y(u, v) {
                if (u < startRadian) {
                                return Math.sin(startRadian) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate ;
                }
                if (u > endRadian) {
                                return Math.sin(endRadian) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * k) * hoverRate ;
                }
                return Math.sin(u) * (0.6 + (Math.cos(v) > 0 ? Math.cos(v) : 0 ) * 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;
                }
                // 当前图形的高度是Z根据h(每个value的值决定的)
                return Math.sin(v) > 0 ? 1 * h * 0.1 : -h;
},
};
}
// 生成模拟 3D 饼图的配置项
function getPie3D(pieData, internalDiameterRatio) {
const series = [];
// 总和
let sumValue = 0;
let startValue = 0;
let endValue = 0;
const legendData = [];
const k =
typeof internalDiameterRatio !== 'undefined'
                ? (1 - internalDiameterRatio) / (1 + internalDiameterRatio)
                : 1 / 3;

// 为每一个饼图数据,生成一个 series-surface 配置
for (let i = 0; i < pieData.length; i += 1) {
sumValue += pieData[i].value;

const 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,
                },
                radius: '20%',
center: ['20%', '20%'],
};

if (typeof pieData[i].itemStyle !== 'undefined') {
                const { itemStyle } = pieData[i];

                // eslint-disable-next-line no-unused-expressions
                typeof pieData[i].itemStyle.color !== 'undefined' ? (itemStyle.color = pieData[i].itemStyle.color) : null;
                // eslint-disable-next-line no-unused-expressions
                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,也就是实现每一个扇形。
console.log(series);
for (let i = 0; i < series.length; i += 1) {
endValue = startValue + series[i].pieData.value;
const rate = (endValue - startValue) / sumValue
series[i].pieData.startRatio = startValue / sumValue;
series[i].pieData.endRatio = endValue / sumValue;
series[i].pieStatus.k = rate * 3;
series[i].parametricEquation = getParametricEquation(
                series[i].pieData.startRatio,
                series[i].pieData.endRatio,
                false,
                false,
                rate * 3,
                // 0.2 + Math.random() / 3,
                // 我这里做了一个处理,使除了第一个之外的值都是10
                // series[i].pieData.value === series[0].pieData.value ? 35 : 10
                5,
);

startValue = endValue;

legendData.push(series[i].name);
}
series.push({
name: 'pie2d',
type: 'pie',
label: {
        opacity: 1,
        position: 'outside',
        fontSize: 28,
        lineHeight: 32,
        alignTo: 'edge',
        color: 'inherit',
        edgeDistance: 10,
        textStyle: {
                fontSize: 28,
                // color: 'red'
        },
        alignTo: 'edge',
        formatter: '{b}\n{c} 次',
        minMargin: 5,
},
// labelLine: {
// 	length: 80,
// 	length2: 80,
// 	maxSurfaceAngle: 80
// },
// labelLayout: function (params) {
// 	const isLeft = params.labelRect.x < myChart.getWidth() / 2;
// 	const points = params.labelLinePoints;
// 	// Update the end point.
// 	points[2][0] = isLeft
// 		? params.labelRect.x
// 		: params.labelRect.x + params.labelRect.width;
// 	return {
// 		labelLinePoints: points
// 	};
// },
minAngle: 10, 
startAngle: -30, // 起始角度,支持范围[0, 360]。
clockwise: false, // 饼图的扇区是否是顺时针排布。上述这两项配置主要是为了对齐3d的样式
radius: ['20%', '45%'],
center: ['50%', '45%'],
data: pieData.filter(e => e.value),
itemStyle: {
        opacity: 0,
},
})
// 准备待返回的配置项,把准备好的 legendData、series 传入。
const option = {
// animation: false,
// tooltip: {
// 		formatter: (params) => {
// 				if (params.seriesName !== 'mouseoutSeries') {
// 						return `<span style="font-size: 28px;line-height: 32px;">${
// 								params.seriesName
// 						}<br/><span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${
// 								params.color
// 						};"></span>${option.series[params.seriesIndex].pieData.value}次</span>`;
// 				}
// 				return '';
// 		},
// },
labelLine: {
        show: true,
        lineStyle: {
                color: '#7BC0CB'
        },
        normal: {
                show: true,
                length: 10,
                length2: 10
        }
},
label: {
        show: true,
        position: 'outside',
        formatter: '{b} \n{d}%',
        textStyle: {
                color: '#fff',
                fontSize: '12px'
        }
},
xAxis3D: {
                min: -1,
                max: 1,
},
yAxis3D: {
                min: -1,
                max: 1,
},
zAxis3D: {
                min: -1,
                max: 1,
},
grid3D: {
                show: false,
                boxHeight: 5,
                top: '-10%',
                viewControl: {
                                // 3d效果可以放大、旋转等,请自己去查看官方配置
                                alpha: 60,
                                beta: 30,
                                rotateSensitivity: 1,
                                zoomSensitivity: 0,
                                panSensitivity: 0,
                                distance: 200,
                },
                // 后处理特效可以为画面添加高光、景深、环境光遮蔽(SSAO)、调色等效果。可以让整个画面更富有质感。
                postEffect: {
                                // 配置这项会出现锯齿,请自己去查看官方配置有办法解决
                                enable: false,
                                bloom: {
                                                enable: true,
                                                bloomIntensity: 0.1,
                                },
                                SSAO: {
                                                enable: true,
                                                quality: 'medium',
                                                radius: 2,
                                },
                                // temporalSuperSampling: {
                                //   enable: true,
                                // },
                },
},
series,
};
return option;
}
//  修正取消高亮失败的 bug
// 监听 mouseover,近似实现高亮(放大)效果
myChart.on('mouseover', function (params) {
// 准备重新渲染扇形所需的参数
let isSelected;
let isHovered;
let startRatio;
let endRatio;
let k;
let i;

// 如果触发 mouseover 的扇形当前已高亮,则不做操作
if (hoveredIndex === params.seriesIndex) {
return;

// 否则进行高亮及必要的取消高亮操作
} else {
// 如果当前有高亮的扇形,取消其高亮状态(对 option 更新)
if (hoveredIndex !== '') {
                // 从 option.series 中读取重新渲染扇形所需的参数,将是否高亮设置为 false。
                isSelected = option.series[hoveredIndex].pieStatus.selected;
                isHovered = false;
                startRatio = option.series[hoveredIndex].pieData.startRatio;
                endRatio = option.series[hoveredIndex].pieData.endRatio;
                k = option.series[hoveredIndex].pieStatus.k;
                i = option.series[hoveredIndex].pieData.value === option.series[0].pieData.value ? 35 : 10;
                // 对当前点击的扇形,执行取消高亮操作(对 option 更新)
                option.series[hoveredIndex].parametricEquation = getParametricEquation(
                                startRatio,
                                endRatio,
                                isSelected,
                                isHovered,
                                k,
                                5
                );
                option.series[hoveredIndex].pieStatus.hovered = isHovered;

                // 将此前记录的上次选中的扇形对应的系列号 seriesIndex 清空
                hoveredIndex = '';
}

// 如果触发 mouseover 的扇形不是透明圆环,将其高亮(对 option 更新)
if (params.seriesName !== 'mouseoutSeries') {
                // 从 option.series 中读取重新渲染扇形所需的参数,将是否高亮设置为 true。
                isSelected = option.series[params.seriesIndex].pieStatus.selected;
                isHovered = true;
                startRatio = option.series[params.seriesIndex].pieData.startRatio;
                endRatio = option.series[params.seriesIndex].pieData.endRatio;
                k = option.series[params.seriesIndex].pieStatus.k;
                // 对当前点击的扇形,执行高亮操作(对 option 更新)
                option.series[params.seriesIndex].parametricEquation = getParametricEquation(
                                startRatio,
                                endRatio,
                                isSelected,
                                isHovered,
                                k,
                                5
                );
                option.series[params.seriesIndex].pieStatus.hovered = isHovered;

                // 记录上次高亮的扇形对应的系列号 seriesIndex
                hoveredIndex = params.seriesIndex;
}

// 使用更新后的 option,渲染图表
myChart.setOption(option);
}
});

// 修正取消高亮失败的 bug
myChart.on('globalout', function () {
if (hoveredIndex !== '') {
// 从 option.series 中读取重新渲染扇形所需的参数,将是否高亮设置为 true。
isSelected = option.series[hoveredIndex].pieStatus.selected;
isHovered = false;
k = option.series[hoveredIndex].pieStatus.k;
startRatio = option.series[hoveredIndex].pieData.startRatio;
endRatio = option.series[hoveredIndex].pieData.endRatio;
// 对当前点击的扇形,执行取消高亮操作(对 option 更新)
i = option.series[hoveredIndex].pieData.value === option.series[0].pieData.value ? 35 : 10;
option.series[hoveredIndex].parametricEquation = getParametricEquation(
                startRatio,
                endRatio,
                isSelected,
                isHovered,
                k,
                5
);
option.series[hoveredIndex].pieStatus.hovered = isHovered;

// 将此前记录的上次选中的扇形对应的系列号 seriesIndex 清空
hoveredIndex = '';
}

// 使用更新后的 option,渲染图表
myChart.setOption(option);
});
option&&myChart.setOption(option);
</script>
</body>

</html>
相关推荐
passerby606118 分钟前
完成前端时间处理的另一块版图
前端·github·web components
掘了25 分钟前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅28 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc