493 lines
14 KiB
Vue
493 lines
14 KiB
Vue
<template>
|
||
<div ref="chartRef" :style="{ width: width, height: height }"></div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, onMounted, nextTick } from 'vue';
|
||
import { cloneDeep } from 'lodash';
|
||
import { useEcharts } from '@/hooks/useEcharts';
|
||
|
||
defineOptions({ name: 'NewHyalineCake' });
|
||
|
||
// 定义组件 props
|
||
const props = defineProps({
|
||
chartData: {
|
||
type: Array,
|
||
default: () => [
|
||
// 默认示例数据
|
||
{ name: '项目一', value: 60 },
|
||
{ name: '项目二', value: 44 },
|
||
{ name: '项目三', value: 32 },
|
||
],
|
||
},
|
||
option: {
|
||
type: Object,
|
||
default: () => ({
|
||
// 控制内外径关系的系数,1 表示无内径(实心饼),值越小内径越大
|
||
k: 1,
|
||
// 扇形边缘向外偏移距离比例(用于选中效果),单位视图坐标,可调
|
||
itemGap: 0.2,
|
||
// 扇形高度(影响z轴拉伸程度)
|
||
itemHeight: 120,
|
||
// 自动计算扇形高度时使用的系数(>0时 itemHeight 失效,使用 autoItemHeight * value )
|
||
autoItemHeight: 0,
|
||
// 透明度设置
|
||
opacity: 0.6,
|
||
// 图例后缀
|
||
legendSuffix: '',
|
||
}),
|
||
},
|
||
width: {
|
||
type: String,
|
||
default: '100%',
|
||
},
|
||
height: {
|
||
type: String,
|
||
default: '100%',
|
||
},
|
||
});
|
||
|
||
// 声明组件触发的事件
|
||
const emit = defineEmits(['click']);
|
||
|
||
// 绑定到DOM的容器引用
|
||
const chartRef = ref(null);
|
||
// 使用 useEcharts 钩子初始化 ECharts 实例,并获取控制方法
|
||
const { setOptions, getInstance } = useEcharts(chartRef);
|
||
|
||
// 存储当前的 ECharts 配置项
|
||
const chartOption = ref({});
|
||
|
||
// 参数方程函数:生成每个扇形曲面的参数方程,用于 series-surface.parametricEquation
|
||
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) {
|
||
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;
|
||
|
||
// 返回 parametric 曲面方程
|
||
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);
|
||
}
|
||
// 在 u > 2.5π 时,处理扇形尾部厚度
|
||
if (u > Math.PI * 2.5) {
|
||
return Math.sin(u) * h * 0.1;
|
||
}
|
||
// 正常情况下,z 根据 v 参数控制上下表面的高度差
|
||
// 当前图形的高度是Z根据h(每个value的值决定的)
|
||
return Math.sin(v) > 0 ? 1 * h * 0.1 : -1;
|
||
},
|
||
};
|
||
}
|
||
|
||
// 生成整个 3D 饼图的配置项
|
||
function getPie3D(pieData) {
|
||
const series = [];
|
||
let sumValue = 0;
|
||
// 计算总值
|
||
pieData.forEach((item) => {
|
||
sumValue += item.value;
|
||
});
|
||
|
||
// k 为外径厚度系数
|
||
const k = props.option.k ?? 1;
|
||
// 构建每个扇形的 series 数据
|
||
pieData.forEach((dataItem, idx) => {
|
||
const seriesItem = {
|
||
name: dataItem.name ?? `series${idx}`,
|
||
type: 'surface',
|
||
parametric: true,
|
||
wireframe: { show: false },
|
||
pieData: dataItem,
|
||
itemStyle: {
|
||
opacity: props.option.opacity,
|
||
borderRadius: 300,
|
||
borderColor: '#fff',
|
||
borderWidth: 0,
|
||
},
|
||
pieStatus: {
|
||
selected: false,
|
||
hovered: false,
|
||
k,
|
||
},
|
||
};
|
||
// 合并自定义样式(如果有)
|
||
if (dataItem.itemStyle) {
|
||
const customStyle = {};
|
||
if (dataItem.itemStyle.color !== undefined) {
|
||
customStyle.color = dataItem.itemStyle.color;
|
||
}
|
||
if (dataItem.itemStyle.opacity !== undefined) {
|
||
customStyle.opacity = dataItem.itemStyle.opacity;
|
||
}
|
||
seriesItem.itemStyle = { ...seriesItem.itemStyle, ...customStyle };
|
||
}
|
||
series.push(seriesItem);
|
||
});
|
||
|
||
// 计算每个扇形的 startRatio/endRatio 和参数方程
|
||
let startValue = 0;
|
||
series.forEach((serie) => {
|
||
const endValue = startValue + serie.pieData.value;
|
||
const startRatio = startValue / sumValue;
|
||
const endRatio = endValue / sumValue;
|
||
serie.pieData.startRatio = startRatio;
|
||
serie.pieData.endRatio = endRatio;
|
||
// 初始均为未选中未高亮
|
||
serie.parametricEquation = getParametricEquation(
|
||
startRatio,
|
||
endRatio,
|
||
false,
|
||
true,
|
||
k,
|
||
// 扇形高度:根据配置自动计算或使用固定高度
|
||
props.option.autoItemHeight > 0 ? props.option.autoItemHeight * serie.pieData.value : props.option.itemHeight
|
||
);
|
||
startValue = endValue;
|
||
});
|
||
|
||
// 添加一个透明圆环,用于实现 hover 在扇形外面时取消高亮
|
||
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(u, v) {
|
||
return Math.sin(v) * Math.sin(u) + Math.sin(u);
|
||
},
|
||
y(u, v) {
|
||
return Math.sin(v) * Math.cos(u) + Math.cos(u);
|
||
},
|
||
z(u, v) {
|
||
return Math.cos(v) > 0 ? 0.1 : -0.1;
|
||
},
|
||
},
|
||
});
|
||
|
||
// 基础配置:坐标轴、视角、图例、提示框等
|
||
const option = Object.assign(
|
||
{
|
||
tooltip: {
|
||
backgroundColor: 'rgba(18, 55, 85, 0.8)',
|
||
borderColor: '#35d0c0',
|
||
color: '#fff',
|
||
position: (point, params, dom, rect, size) => {
|
||
// 动态调整 tooltip 位置,避免溢出
|
||
let x = point[0],
|
||
y = point[1];
|
||
const [viewW, viewH] = size.viewSize;
|
||
const [boxW, boxH] = size.contentSize;
|
||
if (x + boxW > viewW) x -= boxW;
|
||
if (y + boxH > viewH) y -= boxH;
|
||
if (x < 0) x = 0;
|
||
if (y < 0) y = 0;
|
||
return [x, y];
|
||
},
|
||
formatter: (params) => {
|
||
// 只对非透明环(实际扇形)显示信息
|
||
if (params.seriesName !== 'mouseoutSeries') {
|
||
return `
|
||
<span style="color:#FFF">
|
||
${params.seriesName}<br/>
|
||
<span style="
|
||
display:inline-block;
|
||
margin-right:5px;
|
||
border-radius:10px;
|
||
width:10px;
|
||
height:10px;
|
||
background-color:${params.color};"></span>
|
||
${chartOption.value.series[params.seriesIndex].pieData.value}
|
||
</span>`;
|
||
}
|
||
return '';
|
||
},
|
||
},
|
||
xAxis3D: { min: -1, max: 1 },
|
||
yAxis3D: { min: -1, max: 1 },
|
||
zAxis3D: { min: -1, max: 1 },
|
||
grid3D: {
|
||
show: false,
|
||
boxHeight: 5,
|
||
top: '0',
|
||
left: '-20%',
|
||
viewControl: {
|
||
// 3D 视角设置
|
||
alpha: 60, // 俯仰角度
|
||
distance: 240, // 视角距离
|
||
rotateSensitivity: 10,
|
||
zoomSensitivity: 10,
|
||
panSensitivity: 10,
|
||
autoRotate: true,
|
||
autoRotateAfterStill: 2,
|
||
},
|
||
},
|
||
legend: {
|
||
show: true,
|
||
selectedMode: false,
|
||
right: '5%',
|
||
top: '25%',
|
||
orient: 'vertical',
|
||
icon: 'circle',
|
||
itemHeight: 12,
|
||
itemWidth: 12,
|
||
itemGap: 10,
|
||
textStyle: {
|
||
color: '#fff',
|
||
fontSize: 14,
|
||
fontWeight: '400',
|
||
},
|
||
// 图例标签显示名称和数值
|
||
formatter: (name) => {
|
||
const item = props.chartData.find((d) => d.name === name);
|
||
return item ? ` ${name} ${item.value}${props.option.legendSuffix || ''}` : name;
|
||
},
|
||
},
|
||
series,
|
||
},
|
||
// 将外部配置项覆盖基础配置
|
||
props.option
|
||
);
|
||
|
||
return option;
|
||
}
|
||
|
||
// 初始化图表
|
||
function initChart() {
|
||
// 生成基础的3D饼图配置
|
||
const baseOption = getPie3D(props.chartData);
|
||
// 合并用户配置,确保不修改原始对象
|
||
const finalOption = Object.assign({}, 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);
|
||
// 触发组件 click 事件供父组件使用
|
||
emit('click', params);
|
||
}
|
||
// 在函数顶部声明(确保作用域覆盖所有需要的地方)
|
||
window.debugZValues = {
|
||
current: null,
|
||
history: [],
|
||
};
|
||
function handleMouseover(params) {
|
||
if (params.seriesName === 'mouseoutSeries') return;
|
||
|
||
const chart = getInstance();
|
||
const optionVal = chart.getOption(); // 改用实时获取最新配置
|
||
const series = optionVal.series;
|
||
const idx = params.seriesIndex;
|
||
const hh = series[idx].parametricEquation.z();
|
||
window.debugZValues.current = hh;
|
||
window.debugZValues.history.push({
|
||
event: 'mouseover',
|
||
series: series[idx].name,
|
||
zValue: hh,
|
||
time: new Date().toISOString(),
|
||
});
|
||
console.log('当前Z值:', hh, '历史记录:', window.debugZValues.history);
|
||
// 打印移入前的完整状态(调试用)
|
||
console.log(
|
||
'[移入] 当前所有扇形状态',
|
||
series.map((s) => ({
|
||
name: s.name,
|
||
hovered: s.pieStatus?.hovered,
|
||
selected: s.pieStatus?.selected,
|
||
height: s.parametricEquation?.z(Math.PI, Math.PI),
|
||
}))
|
||
);
|
||
|
||
// 取消之前的高亮(确保只修改状态,不破坏参数方程)
|
||
if (hoveredIndex !== null && hoveredIndex !== idx) {
|
||
const prev = series[hoveredIndex];
|
||
prev.pieStatus.hovered = false; // 仅修改状态
|
||
prev.parametricEquation = getParametricEquation(
|
||
// 重新生成正确方程
|
||
prev.pieData.startRatio,
|
||
prev.pieData.endRatio,
|
||
prev.pieStatus.selected,
|
||
false, // isHovered=false
|
||
prev.pieStatus.k,
|
||
prev.pieData.value
|
||
);
|
||
}
|
||
|
||
// 设置新高亮
|
||
const current = series[idx];
|
||
current.pieStatus.hovered = true;
|
||
current.parametricEquation = getParametricEquation(
|
||
current.pieData.startRatio,
|
||
current.pieData.endRatio,
|
||
current.pieStatus.selected,
|
||
true, // isHovered=true
|
||
current.pieStatus.k,
|
||
current.pieData.value
|
||
);
|
||
|
||
hoveredIndex = idx;
|
||
chart.setOption({ series }); // 仅更新series部分
|
||
}
|
||
function handleGlobalout() {
|
||
if (hoveredIndex !== null) {
|
||
const chart = getInstance();
|
||
const optionVal = chart.getOption();
|
||
const series = optionVal.series;
|
||
const prev = series[hoveredIndex];
|
||
|
||
// 打印修复前的错误状态(调试用)
|
||
console.warn('[修复前] 异常状态', {
|
||
name: prev.name,
|
||
z: prev.parametricEquation.z(Math.PI, Math.PI),
|
||
equation: prev.parametricEquation,
|
||
});
|
||
|
||
// 重置状态并重新生成方程
|
||
prev.pieStatus.hovered = false;
|
||
prev.parametricEquation = getParametricEquation(
|
||
prev.pieData.startRatio,
|
||
prev.pieData.endRatio,
|
||
prev.pieStatus.selected,
|
||
false, // isHovered=false
|
||
prev.pieStatus.k,
|
||
prev.pieData.value
|
||
);
|
||
|
||
hoveredIndex = null;
|
||
chart.setOption({ series }, { replaceMerge: 'series' }); // 强制替换series
|
||
}
|
||
}
|
||
// 记录当前选中和高亮的系列索引
|
||
let selectedIndex = null;
|
||
let hoveredIndex = null;
|
||
|
||
// 组件挂载后绑定事件
|
||
onMounted(() => {
|
||
initChart();
|
||
});
|
||
|
||
// 监听数据或配置变更,重新初始化图表
|
||
watch(
|
||
[() => props.chartData, () => props.option],
|
||
() => {
|
||
if (props.chartData && props.chartData.length) {
|
||
initChart();
|
||
}
|
||
},
|
||
{ immediate: true, deep: true }
|
||
);
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 可根据需要自定义图表容器样式 */
|
||
</style>
|