<!-- @/components/MultiYAxisLineChart.vue -->
<template>
<div
ref="chartRef"
:style="{ width: width, height: height }"
:class="['chart-container', `bg-${bgStyleType}`]"
></div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { cloneDeep } from 'lodash'
import { getLastNDaysDates } from '@/utils/dateFormatter'
// Props 定义
const props = defineProps({
// X 轴数据
xAxisData: {
type: Array,
required: true
},
// 系列数据:[{ name, data, type, isTime , lineType // line bar}]
series: {
type: Array,
required: true
},
// 图表标题
title: {
type: String,
default: ''
},
// 宽度
width: {
type: String,
default: '100%'
},
// 高度
height: {
type: String,
default: '100%'
},
icon: {
type: String,
default: 'circle'
},
smooth: {
type: Boolean,
default: false
},
colorData: {
default: [
{ spanColor: '#007DFF', code: 'run' },
{ spanColor: '#64BB5C', code: 'singleRun' },
{ spanColor: '#FF9800', code: 'dailyTrainingLoad' },
{ spanColor: '#FFBF00', code: 'max' },
{ spanColor: '#5EA1FF', code: 'training' },
{ spanColor: '#00BEB0', code: 'fitness' },
{ spanColor: '#B566FF', code: 'fatigue' },
{ spanColor: '#EB4667', code: 'runHeartRate' },
{ spanColor: '#FB6522', code: 'runPaced' },
{ spanColor: '#8A2BE2', code: 'sleepScore' },
{ spanColor: '#E14ADB', code: 'sleepHour' },
{ spanColor: '#DE3737', code: 'restHeartRate' },
{ spanColor: '#954CD9', code: 'hrv' }
]
},
optionsObj: {
type: Object,
default: {
interval: 30,
showSymbol: true
}
},
bgStyleType: {
type: String,
default: 'b'
},
singleYAxis: {
type: Boolean,
default: false
}
})
const chartRef = ref(null)
let chartInstance = null
// 获取最近七天的日期
// 生成最近4周的日期(格式 MM/DD)
function generateDates(): string[] {
const dates: string[] = []
const now = new Date()
for (let i = 27; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
dates.push(`${month}/${day}`)
}
return dates
}
const generateOption = () => {
const processedSeries = props.series.map((s, index) => {
const seriesItem = s as any
let displayData = [...(s as any).data]
let maxVal = 0
let minVal = 0
const yAxisGroup = seriesItem.yAxisGroup ?? String(index)
const validValues = (s as any).data.filter(
(val) => val != null && !isNaN(val)
)
maxVal = Math.max(...validValues)
minVal = Math.min(...validValues)
return { ...seriesItem, yAxisGroup, displayData, maxVal, minVal }
})
let yAxes = []
let series = []
if (props.singleYAxis) {
let totalMin
let totalMax
processedSeries.forEach((s) => {
totalMin = Math.min(totalMin, s.minVal)
totalMax = Math.max(totalMax, s.maxVal)
})
const hasSleepScore = processedSeries.some((s) => s.code === 'sleepScore')
const hasSleepHour = processedSeries.some((s) => s.code === 'sleepHour')
let minValue = () => {
if (hasSleepScore) return 0
if (totalMin === totalMax) return Math.floor(totalMin) - 3
return Math.floor(totalMin)
}
let maxValue = () => {
if (hasSleepScore) return 100
if (totalMin === totalMax) return Math.ceil(totalMin) + 3
let max = Math.ceil(totalMax)
let min = Math.floor(totalMin)
let range = max - min
return range % 5 === 0 ? max : max + (5 - (range % 5))
}
const yAxisConfig = {
type: 'value',
position: 'right', // 所有单轴图表都显示在右边
axisLabel: {
formatter: (value: number) => {
if (hasSleepHour) return `${value}h`
if (processedSeries.some((s) => s.code === 'runPaced')) {
return formatSecondsToMMSS(value)
}
return `${value}`
},
color: '#ffffff'
},
nameTextStyle: { color: '#ffffff' },
splitLine: {
show: true,
lineStyle: {
color: '#56565c',
width: 1,
type: 'solid'
}
},
interval: (maxValue() - minValue()) / 5,
min: minValue,
max: maxValue
}
yAxes = [yAxisConfig]
// 所有 series 使用 yAxisIndex: 0
series = processedSeries.map((s, index) => {
const color = props.colorData[index % props.colorData.length]
return {
name: s.name,
type: s.lineType || 'line',
data: s.data,
yAxisIndex: 0, // 全部指向第一个(也是唯一)Y 轴
barWidth: s.lineType === 'bar' ? 8 : undefined,
showSymbol: props.optionsObj.showSymbol ?? true,
symbolSize: 6,
lineStyle: { width: 2.5 },
itemStyle: {
color,
borderRadius: s.lineType === 'bar' ? [4, 4, 0, 0] : undefined
},
connectNulls: true,
smooth: props.smooth
}
})
} else {
processedSeries.forEach((s, index) => {
const color = props.colorData.find(
(item: any) => item.code === s.code
)?.spanColor
const isLeft = index % 2 !== 0
const offset = Math.floor(index / 2) * 30
let yAxisConfig
let minValue = () => {
if (s.code === 'sleepScore') {
return 0
}
if (Number.isFinite(s.maxVal) && Number.isFinite(s.minVal)) {
if (s.minVal == s.maxVal) {
return Math.floor(s.minVal) - 3
}
return Math.floor(s.minVal)
} else {
return 0
}
}
let maxValue = () => {
if (s.code === 'sleepScore') {
return 100
}
if (Number.isFinite(s.maxVal) && Number.isFinite(s.minVal)) {
let max = Math.ceil(s.maxVal)
let min = Math.floor(s.minVal)
let range = max - min
if (s.minVal == s.maxVal) {
return min + 2
}
if (range % 5 === 0) {
return max
} else {
return max + (5 - (range % 5))
}
} else if (s.code === 'sleepHour') {
return 10
} else {
return 100
}
}
yAxisConfig = {
type: 'value',
position: isLeft ? 'left' : 'right',
offset,
axisLabel: {
formatter: (value: number) => {
if (s.code === 'sleepHour') {
return `${value}h`
}
if (s.code === 'runPaced') {
return formatSecondsToMMSS(value)
}
return `${value}`
},
color
},
nameTextStyle: { color },
splitLine: {
show: true,
lineStyle: {
color: '#56565c',
width: 1,
type: 'solid'
}
},
interval: (maxValue() - minValue()) / 5,
min: minValue,
max: maxValue
}
yAxes.push(yAxisConfig)
series.push({
name: s.name,
type: s.lineType || 'line',
data: s.displayData,
yAxisIndex: index,
barWidth: 8,
showSymbol: props.optionsObj.showSymbol ?? true,
symbolSize: 6,
lineStyle: { width: 2.5 },
itemStyle: { color, borderRadius: [4, 4, 0, 0] },
connectNulls: true,
smooth: props.smooth
})
})
}
if (yAxes.length > 0) {
return {
title: {
text: props.title,
left: 'center',
textStyle: { color: '#0d7ee7' }
},
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const index = params[0].dataIndex
const dates = getLastNDaysDates(props.xAxisData.length)
const realDate = dates[index]
let result = `${realDate}<br/>`
params.forEach((p) => {
let valueDisplay = p.value
if (valueDisplay == null) {
valueDisplay = '-'
} else {
valueDisplay = valueDisplay.toFixed(2)
}
result += `${p.marker}${p.seriesName}: ${valueDisplay}<br/>`
})
return result
}
},
legend: {
data: props.series.map((s: any) => {
return {
name: s.name,
icon: s.lineType === 'bar' ? 'rect' : 'circle'
}
}),
left: 'center',
bottom: props.optionsObj.legendBottom ?? 8,
itemWidth: 12,
itemHeight: 12,
itemGap: 130,
icon: props.icon,
selectedMode: false,
textStyle: { color: '#fff' }
},
grid: {
left: props.optionsObj.left ?? 20,
right: props.optionsObj.right ?? 20,
top: props.optionsObj.top ?? 28,
bottom: props.optionsObj.bottom ?? 50,
containLabel: true
},
xAxis: {
type: 'category',
data: props.xAxisData.length > 0 ? props.xAxisData : generateDates(),
axisLine: { lineStyle: { color: '#7a7b7c' } },
axisLabel: { color: '#7a7b7c' },
axisTick: {
show: false
}
},
yAxis: cloneDeep(yAxes),
series,
alignTicks: true
}
}
return {
xAxis: {
type: 'category',
data: props.xAxisData.length > 0 ? props.xAxisData : generateDates(),
axisLine: { lineStyle: { color: '#7a7b7c' } },
axisLabel: { color: '#7a7b7c' },
axisTick: {
show: false
}
},
grid: {
left: '8%',
right: '8%',
top: '20%',
bottom: '20%',
containLabel: true
},
yAxis: {
type: 'value',
max: 100,
min: 0,
interval: 20,
axisLabel: {
color: '#7a7b7c'
},
splitLine: {
show: true,
lineStyle: {
color: '#56565c',
width: 1,
type: 'solid'
}
}
}
}
}
// 将秒转换为分秒
const formatSecondsToMMSS = (seconds) => {
const minutes = Math.floor(seconds / 60) // 计算分钟
const remainingSeconds = seconds % 60 // 计算剩余的秒数
// 使用字符串填充,确保两位数
const formattedMinutes = String(minutes).padStart(2, '0')
const formattedSeconds = String(remainingSeconds).padStart(2, '0')
return `${formattedMinutes}'${formattedSeconds}''`
}
// 初始化图表
const initChart = () => {
nextTick(() => {
if (!chartRef.value) return
// 销毁旧实例
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const option = generateOption()
chartInstance.setOption(option)
// 绑定 resize
window.addEventListener('resize', handleResize)
})
}
// resize 处理
const handleResize = () => {
chartInstance?.resize()
}
// 挂载时初始化
onMounted(() => {
nextTick(() => {
initChart()
})
})
// 数据或属性变化时更新图表
watch(
() => [props.xAxisData, props.series, props.title],
() => {
nextTick(() => {
initChart()
})
},
{ deep: true }
)
watch(
() => props.series,
(newVal, oldVal) => {
if (newVal !== oldVal) {
initChart()
}
},
{ deep: true }
)
</script>
<style scoped>
.chart-container {
position: relative;
}
.bg-a::after {
margin-left: 24px;
width: calc(100% - 48px);
height: 100%;
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0)
);
border-radius: 12px;
z-index: -1;
pointer-events: none;
}
.bg-b::after {
width: 100%;
height: 100%;
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0)
);
border-radius: 12px;
z-index: -1;
pointer-events: none;
}
</style>
我要样式与多轴保持一直 只是从多轴变成单轴