Files
art-design/src/components/core/charts/art-line-chart/index.vue
T
2026-01-10 10:10:57 +08:00

378 lines
9.6 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"
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>