378 lines
9.6 KiB
Vue
378 lines
9.6 KiB
Vue
<!-- 折线图,支持多组数据,支持阶梯式动画效果 -->
|
||
<template>
|
||
<div
|
||
ref="chartRef"
|
||
class="relative w-[calc(100%+10px)]"
|
||
:style="{ height: props.height }"
|
||
v-loading="props.loading"
|
||
>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
|
||
|
||
defineOptions({ name: 'ArtLineChart' })
|
||
|
||
const props = withDefaults(defineProps<LineChartProps>(), {
|
||
// 基础配置
|
||
height: useChartOps().chartHeight,
|
||
loading: false,
|
||
isEmpty: false,
|
||
colors: () => useChartOps().colors,
|
||
|
||
// 数据配置
|
||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||
xAxisData: () => [],
|
||
lineWidth: 2.5,
|
||
showAreaColor: false,
|
||
smooth: true,
|
||
symbol: 'none',
|
||
symbolSize: 6,
|
||
animationDelay: 200,
|
||
|
||
// 轴线显示配置
|
||
showAxisLabel: true,
|
||
showAxisLine: true,
|
||
showSplitLine: true,
|
||
|
||
// 交互配置
|
||
showTooltip: true,
|
||
showLegend: false,
|
||
legendPosition: 'bottom'
|
||
})
|
||
|
||
// 动画状态管理
|
||
const isAnimating = ref(false)
|
||
const animationTimers = ref<number[]>([])
|
||
const animatedData = ref<number[] | LineDataItem[]>([])
|
||
|
||
// 清理所有定时器
|
||
const clearAnimationTimers = () => {
|
||
animationTimers.value.forEach((timer) => clearTimeout(timer))
|
||
animationTimers.value = []
|
||
}
|
||
|
||
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
|
||
const isMultipleData = computed(() => {
|
||
return (
|
||
Array.isArray(props.data) &&
|
||
props.data.length > 0 &&
|
||
typeof props.data[0] === 'object' &&
|
||
'name' in props.data[0]
|
||
)
|
||
})
|
||
|
||
// 缓存计算的最大值,避免重复计算
|
||
const maxValue = computed(() => {
|
||
if (isMultipleData.value) {
|
||
const multiData = props.data as LineDataItem[]
|
||
return multiData.reduce((max, item) => {
|
||
if (item.data?.length) {
|
||
const itemMax = Math.max(...item.data)
|
||
return Math.max(max, itemMax)
|
||
}
|
||
return max
|
||
}, 0)
|
||
} else {
|
||
const singleData = props.data as number[]
|
||
return singleData?.length ? Math.max(...singleData) : 0
|
||
}
|
||
})
|
||
|
||
// 初始化动画数据(优化:减少条件判断)
|
||
const initAnimationData = (): number[] | LineDataItem[] => {
|
||
if (isMultipleData.value) {
|
||
const multiData = props.data as LineDataItem[]
|
||
return multiData.map((item) => ({
|
||
...item,
|
||
data: Array(item.data.length).fill(0)
|
||
}))
|
||
}
|
||
const singleData = props.data as number[]
|
||
return Array(singleData.length).fill(0)
|
||
}
|
||
|
||
// 复制真实数据(优化:使用结构化克隆)
|
||
const copyRealData = (): number[] | LineDataItem[] => {
|
||
if (isMultipleData.value) {
|
||
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
|
||
}
|
||
return [...(props.data as number[])]
|
||
}
|
||
|
||
// 获取颜色配置(优化:缓存主题色)
|
||
const primaryColor = computed(() => getCssVar('--el-color-primary'))
|
||
|
||
const getColor = (customColor?: string, index?: number): string => {
|
||
if (customColor) return customColor
|
||
if (index !== undefined) return props.colors![index % props.colors!.length]
|
||
return primaryColor.value
|
||
}
|
||
|
||
// 生成区域样式
|
||
const generateAreaStyle = (item: LineDataItem, color: string) => {
|
||
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
|
||
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
|
||
|
||
const areaConfig = item.areaStyle || {}
|
||
if (areaConfig.custom) return areaConfig.custom
|
||
|
||
return {
|
||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||
{
|
||
offset: 0,
|
||
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
|
||
},
|
||
{
|
||
offset: 1,
|
||
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
|
||
}
|
||
])
|
||
}
|
||
}
|
||
|
||
// 生成单数据区域样式
|
||
const generateSingleAreaStyle = () => {
|
||
if (!props.showAreaColor) return undefined
|
||
|
||
const color = getColor(props.colors[0])
|
||
return {
|
||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||
{
|
||
offset: 0,
|
||
color: hexToRgba(color, 0.2).rgba
|
||
},
|
||
{
|
||
offset: 1,
|
||
color: hexToRgba(color, 0.02).rgba
|
||
}
|
||
])
|
||
}
|
||
}
|
||
|
||
// 创建系列配置
|
||
const createSeriesItem = (config: {
|
||
name?: string
|
||
data: number[]
|
||
color?: string
|
||
smooth?: boolean
|
||
symbol?: string
|
||
symbolSize?: number
|
||
lineWidth?: number
|
||
areaStyle?: any
|
||
}) => {
|
||
return {
|
||
name: config.name,
|
||
data: config.data,
|
||
type: 'line' as const,
|
||
color: config.color,
|
||
smooth: config.smooth ?? props.smooth,
|
||
symbol: config.symbol ?? props.symbol,
|
||
symbolSize: config.symbolSize ?? props.symbolSize,
|
||
lineStyle: {
|
||
width: config.lineWidth ?? props.lineWidth,
|
||
color: config.color
|
||
},
|
||
areaStyle: config.areaStyle,
|
||
emphasis: {
|
||
focus: 'series' as const,
|
||
lineStyle: {
|
||
width: (config.lineWidth ?? props.lineWidth) + 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成图表配置
|
||
const generateChartOptions = (isInitial = false): EChartsOption => {
|
||
const options: EChartsOption = {
|
||
animation: true,
|
||
animationDuration: isInitial ? 0 : 1300,
|
||
animationDurationUpdate: isInitial ? 0 : 1300,
|
||
grid: getGridWithLegend(
|
||
props.showLegend && isMultipleData.value,
|
||
props.legendPosition,
|
||
{
|
||
top: 15,
|
||
right: 15,
|
||
left: 0
|
||
}
|
||
),
|
||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||
xAxis: {
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
data: props.xAxisData,
|
||
axisTick: getAxisTickStyle(),
|
||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
min: 0,
|
||
max: maxValue.value,
|
||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||
}
|
||
}
|
||
|
||
// 添加图例配置
|
||
if (props.showLegend && isMultipleData.value) {
|
||
options.legend = getLegendStyle(props.legendPosition)
|
||
}
|
||
|
||
// 生成系列数据
|
||
if (isMultipleData.value) {
|
||
const multiData = animatedData.value as LineDataItem[]
|
||
options.series = multiData.map((item, index) => {
|
||
const itemColor = getColor(props.colors[index], index)
|
||
const areaStyle = generateAreaStyle(item, itemColor)
|
||
|
||
return createSeriesItem({
|
||
name: item.name,
|
||
data: item.data,
|
||
color: itemColor,
|
||
smooth: item.smooth,
|
||
symbol: item.symbol,
|
||
lineWidth: item.lineWidth,
|
||
areaStyle
|
||
})
|
||
})
|
||
} else {
|
||
// 单数据情况
|
||
const singleData = animatedData.value as number[]
|
||
const computedColor = getColor(props.colors[0])
|
||
const areaStyle = generateSingleAreaStyle()
|
||
|
||
options.series = [
|
||
createSeriesItem({
|
||
data: singleData,
|
||
color: computedColor,
|
||
areaStyle
|
||
})
|
||
]
|
||
}
|
||
|
||
return options
|
||
}
|
||
|
||
// 更新图表
|
||
const updateChartOptions = (options: EChartsOption) => {
|
||
initChart(options)
|
||
}
|
||
|
||
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
|
||
const initChartWithAnimation = () => {
|
||
clearAnimationTimers()
|
||
isAnimating.value = true
|
||
|
||
// 初始化为0值数据
|
||
animatedData.value = initAnimationData()
|
||
updateChartOptions(generateChartOptions(true))
|
||
|
||
if (isMultipleData.value) {
|
||
// 多数据阶梯式动画
|
||
const multiData = props.data as LineDataItem[]
|
||
const currentAnimatedData = animatedData.value as LineDataItem[]
|
||
|
||
multiData.forEach((item, index) => {
|
||
const timer = window.setTimeout(
|
||
() => {
|
||
currentAnimatedData[index] = { ...item, data: [...item.data] }
|
||
animatedData.value = [...currentAnimatedData]
|
||
updateChartOptions(generateChartOptions(false))
|
||
},
|
||
index * props.animationDelay + 100
|
||
)
|
||
|
||
animationTimers.value.push(timer)
|
||
})
|
||
|
||
// 标记动画完成
|
||
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
|
||
const finishTimer = window.setTimeout(() => {
|
||
isAnimating.value = false
|
||
}, totalDelay)
|
||
animationTimers.value.push(finishTimer)
|
||
} else {
|
||
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
|
||
nextTick(() => {
|
||
animatedData.value = copyRealData()
|
||
updateChartOptions(generateChartOptions(false))
|
||
isAnimating.value = false
|
||
})
|
||
}
|
||
}
|
||
|
||
// 空数据检查函数
|
||
const checkIsEmpty = () => {
|
||
// 检查单数据情况
|
||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||
const singleData = props.data as number[]
|
||
return !singleData.length || singleData.every((val) => val === 0)
|
||
}
|
||
|
||
// 检查多数据情况
|
||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||
const multiData = props.data as LineDataItem[]
|
||
return (
|
||
!multiData.length ||
|
||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||
)
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// 使用新的图表组件抽象
|
||
const {
|
||
chartRef,
|
||
initChart,
|
||
getAxisLineStyle,
|
||
getAxisLabelStyle,
|
||
getAxisTickStyle,
|
||
getSplitLineStyle,
|
||
getTooltipStyle,
|
||
getLegendStyle,
|
||
getGridWithLegend,
|
||
isEmpty
|
||
} = useChartComponent({
|
||
props,
|
||
checkEmpty: checkIsEmpty,
|
||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||
onVisible: () => {
|
||
// 当图表变为可见时,检查是否为空数据
|
||
if (!isEmpty.value) {
|
||
initChartWithAnimation()
|
||
}
|
||
},
|
||
generateOptions: () => generateChartOptions(false)
|
||
})
|
||
|
||
// 图表渲染函数(优化:防止动画期间重复触发)
|
||
const renderChart = () => {
|
||
if (!isAnimating.value && !isEmpty.value) {
|
||
initChartWithAnimation()
|
||
}
|
||
}
|
||
|
||
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, {
|
||
deep: true
|
||
})
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
renderChart()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clearAnimationTimers()
|
||
})
|
||
</script>
|