2025-05-19 13:45:13 +08:00

436 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div ref="chartRef" :style="{ width: width, height: height }"></div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue';
import { merge, cloneDeep } from 'lodash';
import { useEcharts } from '@/hooks/useEcharts';
defineOptions({ name: 'NewHyalineCake' });
let selectedIndex = null;
let hoveredIndex = null;
const props = defineProps({
chartData: {
type: Array,
default: () => [
{ name: '项目一', value: 60 },
{ name: '项目二', value: 44 },
{ name: '项目三', value: 32 },
],
},
option: {
type: Object,
default: () => ({
k: 1, // 控制内外径关系的系数1 表示无内径,值越小内径越大
itemGap: 0.1, // 扇形的间距
itemHeight: 120, // 扇形高度影响z轴拉伸程度
autoItemHeight: 0, // 自动计算扇形高度时使用的系数(>0时 itemHeight 失效,使用 autoItemHeight * value
opacity: 0.6, // 透明度设置
legendSuffix: '', // 图例后缀
// TODO series
}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
});
const emit = defineEmits(['click']);
const chartRef = ref(null);
const { setOptions, getInstance } = useEcharts(chartRef);
const chartOption = ref({});
/**
* 获取 parametric 曲面方程
* @param {Number} startRatio 起点弧度
* @param {Number} endRatio 终点弧度
* @param {Boolean} isSelected 是否选中
* @param {Boolean} isHovered 是否高亮
* @param {Number} k 饼图内径/外径的占比
* @param {Number} h 饼图高度
* @param {Array} offsetZ 浮动系数
* @return {Object} parametric 曲面方程
*/
function getParametricEquation(startRatio, endRatio, isSelected, isHovered, k, h, offsetZ = 0) {
// console.log('getParametricEquation params :>> ', startRatio, endRatio, isSelected, isHovered, k, h, offsetZ);
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 取默认值 1/3扇形内径/外径),如果传入 k 则使用传入值
k = typeof k !== 'undefined' ? k : 1 / 3;
// 计算选中效果的偏移量(基于扇形中心角度)
const offsetX = isSelected ? Math.cos(midRadian) * props.option.itemGap : 0;
const offsetY = isSelected ? Math.sin(midRadian) * props.option.itemGap : 0;
// 计算高亮时的放大比例未高亮时为1
const hoverRate = isHovered ? 1.05 : 1;
return {
u: {
// u 参数控制饼的周向角度:从 -π 到 3π可以完整绘制一圈
min: -Math.PI,
max: Math.PI * 3,
step: Math.PI / 32,
},
v: {
// v 从 0 - 2π 参数控制扇形的径向方向(厚度方向)
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) {
// 在 u < -π/2 时,防止出现不必要的翻转,直接使用基本正弦
if (u < -Math.PI * 0.5) {
return Math.sin(u) + offsetZ * h * 0.1;
}
// 在 u > 2.5π 时,处理扇形尾部厚度
if (u > Math.PI * 2.5) {
return Math.sin(u) * h * 0.1 + offsetZ * h * 0.1;
}
// 正常情况下z 根据 v 参数控制上下表面的高度差
// 当前图形的高度是Z根据h每个value的值决定的
return Math.sin(v) > 0 ? 1 * h * 0.1 + offsetZ * h * 0.1 : -1 + offsetZ * h * 0.1;
},
};
}
/**
* 获取 3D 饼图的配置项
* @param {Array} pieData 饼图数据
* @param {Number} internalDiameterRatio 饼图内径/外径的占比
* @return {Object} 配置项
*/
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 数据
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: true,
hovered: false,
k,
},
label: {
show: true,
formatter: () => `${pieData[i].value}`, // 直接显示 value
position: 'outside', // 标签位置
distance: 10, // 距扇形边缘的距离
color: '#fff', // 根据背景色调整
fontSize: 14,
},
};
if (typeof pieData[i].itemStyle !== 'undefined') {
const { itemStyle } = pieData[i];
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也就是实现每一个扇形。
// console.log(series);
for (let i = 0; i < series.length; i += 1) {
endValue = startValue + series[i].pieData.value;
const z = series[i]?.pieData?.floatZ ?? 0;
series[i].pieData.startRatio = startValue / sumValue;
series[i].pieData.endRatio = endValue / sumValue;
series[i].parametricEquation = getParametricEquation(
series[i].pieData.startRatio,
series[i].pieData.endRatio,
series[i].pieStatus.selected,
series[i].pieStatus.hovered,
k,
props.option.autoItemHeight > 0 ? props.option.autoItemHeight * series[i].pieData.value : props.option.itemHeight,
z
);
startValue = endValue;
legendData.push(series[i].name);
}
// 基础配置:坐标轴、视角、图例、提示框等
// 准备待返回的配置项,把准备好的 legendData、series 传入。
const option = {
legend: {
type: 'scroll',
show: true,
right: '5%',
top: '25%',
orient: 'vertical',
icon: 'circle',
itemHeight: 12,
itemWidth: 12,
itemGap: 10,
textStyle: {
color: '#fff',
fontSize: 14,
fontWeight: '400',
},
formatter: (name) => {
if (props.chartData.length) {
const item = props.chartData.filter((item) => item.name === name)[0];
return ` ${name} ${item.value}${props.option.legendSuffix ?? ''}`;
}
},
},
// animation: false,
tooltip: {
formatter: (params) => {
if (params.seriesName !== 'mouseoutSeries') {
return `${
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}`;
}
return '';
},
},
xAxis3D: {
min: -1,
max: 1,
},
yAxis3D: {
min: -1,
max: 1,
},
zAxis3D: {
min: -1,
max: 1,
},
grid3D: {
show: true,
boxHeight: 5,
top: '-20%',
viewControl: {
// 3d效果可以放大、旋转等请自己去查看官方配置
alpha: 35,
// beta: 30,
rotateSensitivity: 1,
zoomSensitivity: 0,
panSensitivity: 0,
autoRotate: true,
distance: 150,
},
// 后处理特效可以为画面添加高光、景深、环境光遮蔽SSAO、调色等效果。可以让整个画面更富有质感。
postEffect: {
// 配置这项会出现锯齿,请自己去查看官方配置有办法解决
enable: false,
bloom: {
enable: true,
bloomIntensity: 0.1,
},
SSAO: {
enable: true,
quality: 'medium',
radius: 2,
},
// temporalSuperSampling: {
// enable: true,
// },
},
},
series,
};
return option;
}
// 监听 mouseover近似实现高亮放大效果
function handleMouseover(params) {
// console.log('mouseover');
const idx = params.seriesIndex;
const series = chartOption.value.series;
// 如果当前扇形已经高亮,不做任何操作
if (hoveredIndex === idx) return;
// 1. 取消上一个高亮
if (hoveredIndex !== null && hoveredIndex >= 0 && series[hoveredIndex]) {
updateSeriesHover(hoveredIndex, false);
}
// 2. 设置当前扇形高亮(排除辅助环)
if (params.seriesName !== 'mouseoutSeries' && series[idx]) {
updateSeriesHover(idx, true);
hoveredIndex = idx;
} else {
hoveredIndex = null;
}
// 3. 重新渲染
setOptions(chartOption.value);
}
function handleGlobalout() {
const idx = hoveredIndex;
if (idx !== null && idx >= 0 && chartOption.value.series[idx]) {
updateSeriesHover(idx, false);
hoveredIndex = null;
setOptions(chartOption.value);
}
}
// 抽取高亮/取消高亮的公共逻辑
function updateSeriesHover(index, toHover) {
const item = chartOption.value.series[index];
// 安全取 pieStatusfallback 到空对象
const status = item.pieStatus || {};
const isSelected = !!status.selected;
const start = item.pieData?.startRatio ?? 0;
const end = item.pieData?.endRatio ?? 1;
const k = typeof status.k === 'number' ? status.k : 1 / 3;
// 计算 newHeight如果 hover则加 5否则按原 height
const baseHeight = props.option.autoItemHeight > 0 ? props.option.autoItemHeight : props.option.itemHeight;
const newHeight = toHover ? baseHeight + 5 : baseHeight;
// 更新 parametricEquation
item.parametricEquation = getParametricEquation(start, end, isSelected, toHover, k, newHeight);
// 更新状态
item.pieStatus = {
...status,
hovered: toHover,
};
}
// 初始化图表
function initChart() {
// 生成基础的3D饼图配置(也就是默认的配置)
const baseOption = getPie3D(props.chartData, props.option.k);
// 合并用户配置,确保不修改原始对象
// const finalOption = Object.assign({}, baseOption, cloneDeep(props.option || {}));
const finalOption = merge({}, baseOption, cloneDeep(props.option || {}));
chartOption.value = finalOption;
// 设置图表配置
setOptions(chartOption.value);
// 等待 DOM + ECharts 初始化完成
nextTick(() => {
const chart = getInstance();
if (!chart) {
console.warn('ECharts 实例未初始化,事件绑定失败');
return;
}
// 避免重复绑定事件
// chart.off('click');
chart.off('mouseover');
chart.off('globalout');
// chart.on('click', handleClick);
chart.on('mouseover', handleMouseover);
chart.on('globalout', handleGlobalout);
});
}
function handleClick(params) {
// 如果点击的是透明辅助环,则忽略
if (params.seriesName === 'mouseoutSeries') return;
const optionVal = chartOption.value;
const series = optionVal.series;
const idx = params.seriesIndex;
// 新的选中状态(取反)
const isSelected = !series[idx].pieStatus.selected;
const isHovered = series[idx].pieStatus.hovered;
const k = series[idx].pieStatus.k;
const startRatio = series[idx].pieData.startRatio;
const endRatio = series[idx].pieData.endRatio;
const h = series[idx].pieData.value;
// 如果之前有其他扇形被选中,取消其选中状态
if (selectedIndex !== null && selectedIndex !== idx) {
const prev = series[selectedIndex];
prev.parametricEquation = getParametricEquation(
prev.pieData.startRatio,
prev.pieData.endRatio,
false,
false,
prev.pieStatus.k,
prev.pieData.value
);
prev.pieStatus.selected = false;
selectedIndex = null;
}
// 设置当前扇形的选中/取消选中状态
series[idx].parametricEquation = getParametricEquation(startRatio, endRatio, isSelected, isHovered, k, h);
series[idx].pieStatus.selected = isSelected;
selectedIndex = isSelected ? idx : null;
setOptions(optionVal);
emit('click', params);
}
// 组件挂载后绑定事件
onMounted(() => {
initChart();
});
// 监听数据或配置变更,重新初始化图表
watch(
[() => props.chartData, () => props.option],
() => {
if (props.chartData && props.chartData.length) {
initChart();
}
},
{ immediate: true, deep: true }
);
</script>
<style scoped>
/* 可根据需要自定义图表容器样式 */
</style>