import { echarts } from '@/plugins/echarts'
|
import { storeToRefs } from 'pinia'
|
import { useSettingStore } from '@/store/modules/setting'
|
import { getCssVar } from '@/utils/ui'
|
const useChartOps = () => ({
|
/** */
|
chartHeight: '16rem',
|
/** 字体大小 */
|
fontSize: 13,
|
/** 字体颜色 */
|
fontColor: '#999',
|
/** 主题颜色 */
|
themeColor: getCssVar('--el-color-primary-light-1'),
|
/** 颜色组 */
|
colors: [
|
getCssVar('--el-color-primary-light-1'),
|
'#4ABEFF',
|
'#EDF2FF',
|
'#14DEBA',
|
'#FFAF20',
|
'#FA8A6C',
|
'#FFAF20'
|
]
|
})
|
const RESIZE_DELAYS = [50, 100, 200, 350]
|
const MENU_RESIZE_DELAYS = [50, 100, 200]
|
const RESIZE_DEBOUNCE_DELAY = 100
|
function useChart(options = {}) {
|
const { initOptions, initDelay = 0, threshold = 0.1, autoTheme = true } = options
|
const settingStore = useSettingStore()
|
const { isDark, menuOpen, menuType } = storeToRefs(settingStore)
|
const chartRef = ref()
|
let chart = null
|
let intersectionObserver = null
|
let pendingOptions = null
|
let resizeTimeoutId = null
|
let resizeFrameId = null
|
let isDestroyed = false
|
let emptyStateDiv = null
|
const clearTimers = () => {
|
if (resizeTimeoutId) {
|
clearTimeout(resizeTimeoutId)
|
resizeTimeoutId = null
|
}
|
if (resizeFrameId) {
|
cancelAnimationFrame(resizeFrameId)
|
resizeFrameId = null
|
}
|
}
|
const requestAnimationResize = () => {
|
if (resizeFrameId) {
|
cancelAnimationFrame(resizeFrameId)
|
}
|
resizeFrameId = requestAnimationFrame(() => {
|
handleResize()
|
resizeFrameId = null
|
})
|
}
|
const debouncedResize = () => {
|
if (resizeTimeoutId) {
|
clearTimeout(resizeTimeoutId)
|
}
|
resizeTimeoutId = window.setTimeout(() => {
|
requestAnimationResize()
|
resizeTimeoutId = null
|
}, RESIZE_DEBOUNCE_DELAY)
|
}
|
const multiDelayResize = (delays) => {
|
nextTick(requestAnimationResize)
|
delays.forEach((delay) => {
|
setTimeout(requestAnimationResize, delay)
|
})
|
}
|
let menuOpenStopHandle = null
|
let menuTypeStopHandle = null
|
const setupMenuWatchers = () => {
|
menuOpenStopHandle = watch(menuOpen, () => multiDelayResize(RESIZE_DELAYS))
|
menuTypeStopHandle = watch(menuType, () => {
|
nextTick(requestAnimationResize)
|
setTimeout(() => multiDelayResize(MENU_RESIZE_DELAYS), 0)
|
})
|
}
|
const cleanupMenuWatchers = () => {
|
menuOpenStopHandle?.()
|
menuTypeStopHandle?.()
|
menuOpenStopHandle = null
|
menuTypeStopHandle = null
|
}
|
let themeStopHandle = null
|
const setupThemeWatcher = () => {
|
if (autoTheme) {
|
themeStopHandle = watch(isDark, () => {
|
emptyStateManager.updateStyle()
|
if (chart && !isDestroyed) {
|
requestAnimationFrame(() => {
|
if (chart && !isDestroyed) {
|
const currentOptions = chart.getOption()
|
if (currentOptions) {
|
updateChart(currentOptions)
|
}
|
}
|
})
|
}
|
})
|
}
|
}
|
const cleanupThemeWatcher = () => {
|
themeStopHandle?.()
|
themeStopHandle = null
|
}
|
const createLineStyle = (color, width = 1, type) => ({
|
color,
|
width,
|
...(type && { type })
|
})
|
const styleCache = {
|
axisLine: null,
|
splitLine: null,
|
axisLabel: null,
|
lastDarkValue: isDark.value
|
}
|
const clearStyleCache = () => {
|
styleCache.axisLine = null
|
styleCache.splitLine = null
|
styleCache.axisLabel = null
|
styleCache.lastDarkValue = isDark.value
|
}
|
const getAxisLineStyle = (show = true) => {
|
if (styleCache.lastDarkValue !== isDark.value) {
|
clearStyleCache()
|
}
|
if (!styleCache.axisLine) {
|
styleCache.axisLine = {
|
show,
|
lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED')
|
}
|
}
|
return styleCache.axisLine
|
}
|
const getSplitLineStyle = (show = true) => {
|
if (styleCache.lastDarkValue !== isDark.value) {
|
clearStyleCache()
|
}
|
if (!styleCache.splitLine) {
|
styleCache.splitLine = {
|
show,
|
lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED', 1, 'dashed')
|
}
|
}
|
return styleCache.splitLine
|
}
|
const getAxisLabelStyle = (show = true) => {
|
if (styleCache.lastDarkValue !== isDark.value) {
|
clearStyleCache()
|
}
|
if (!styleCache.axisLabel) {
|
const { fontColor, fontSize } = useChartOps()
|
styleCache.axisLabel = {
|
show,
|
color: fontColor,
|
fontSize
|
}
|
}
|
return styleCache.axisLabel
|
}
|
const getAxisTickStyle = () => ({
|
show: false
|
})
|
const getAnimationConfig = (animationDelay = 50, animationDuration = 1500) => ({
|
animationDelay: (idx) => idx * animationDelay + 200,
|
animationDuration: (idx) => animationDuration - idx * 50,
|
animationEasing: 'quarticOut'
|
})
|
const getTooltipStyle = (trigger = 'axis', customOptions = {}) => ({
|
trigger,
|
backgroundColor: isDark.value ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.9)',
|
borderColor: isDark.value ? '#333' : '#ddd',
|
borderWidth: 1,
|
textStyle: {
|
color: isDark.value ? '#fff' : '#333'
|
},
|
...customOptions
|
})
|
const getLegendStyle = (position = 'bottom', customOptions = {}) => {
|
const baseConfig = {
|
textStyle: {
|
color: isDark.value ? '#fff' : '#333'
|
},
|
itemWidth: 12,
|
itemHeight: 12,
|
itemGap: 20,
|
...customOptions
|
}
|
switch (position) {
|
case 'bottom':
|
return {
|
...baseConfig,
|
bottom: 0,
|
left: 'center',
|
orient: 'horizontal',
|
icon: 'roundRect'
|
}
|
case 'top':
|
return {
|
...baseConfig,
|
top: 0,
|
left: 'center',
|
orient: 'horizontal',
|
icon: 'roundRect'
|
}
|
case 'left':
|
return {
|
...baseConfig,
|
left: 0,
|
top: 'center',
|
orient: 'vertical',
|
icon: 'roundRect'
|
}
|
case 'right':
|
return {
|
...baseConfig,
|
right: 0,
|
top: 'center',
|
orient: 'vertical',
|
icon: 'roundRect'
|
}
|
default:
|
return baseConfig
|
}
|
}
|
const getGridWithLegend = (showLegend, legendPosition = 'bottom', baseGrid = {}) => {
|
const defaultGrid = {
|
top: 15,
|
right: 15,
|
bottom: 8,
|
left: 0,
|
containLabel: true,
|
...baseGrid
|
}
|
if (!showLegend) {
|
return defaultGrid
|
}
|
switch (legendPosition) {
|
case 'bottom':
|
return {
|
...defaultGrid,
|
bottom: 40
|
}
|
case 'top':
|
return {
|
...defaultGrid,
|
top: 40
|
}
|
case 'left':
|
return {
|
...defaultGrid,
|
left: 120
|
}
|
case 'right':
|
return {
|
...defaultGrid,
|
right: 120
|
}
|
default:
|
return defaultGrid
|
}
|
}
|
const createIntersectionObserver = () => {
|
if (intersectionObserver || !chartRef.value) return
|
intersectionObserver = new IntersectionObserver(
|
(entries) => {
|
entries.forEach((entry) => {
|
if (entry.isIntersecting && pendingOptions && !isDestroyed) {
|
requestAnimationFrame(() => {
|
if (!isDestroyed && pendingOptions) {
|
try {
|
if (!chart) {
|
chart = echarts.init(entry.target)
|
}
|
const event = new CustomEvent('chartVisible', {
|
detail: { options: pendingOptions }
|
})
|
entry.target.dispatchEvent(event)
|
pendingOptions = null
|
cleanupIntersectionObserver()
|
} catch (error) {
|
console.error('图表初始化失败:', error)
|
}
|
}
|
})
|
}
|
})
|
},
|
{ threshold }
|
)
|
intersectionObserver.observe(chartRef.value)
|
}
|
const cleanupIntersectionObserver = () => {
|
if (intersectionObserver) {
|
intersectionObserver.disconnect()
|
intersectionObserver = null
|
}
|
}
|
const isContainerVisible = (element) => {
|
const rect = element.getBoundingClientRect()
|
return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0
|
}
|
const performChartInit = (options2) => {
|
if (!chart && chartRef.value && !isDestroyed) {
|
chart = echarts.init(chartRef.value)
|
setupMenuWatchers()
|
setupThemeWatcher()
|
}
|
if (chart && !isDestroyed) {
|
chart.setOption(options2)
|
pendingOptions = null
|
}
|
}
|
const emptyStateManager = {
|
create: () => {
|
if (!chartRef.value || emptyStateDiv) return
|
emptyStateDiv = document.createElement('div')
|
emptyStateDiv.style.cssText = `
|
position: absolute;
|
top: 0;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
font-size: 12px;
|
color: ${isDark.value ? '#555555' : '#B3B2B2'};
|
background: transparent;
|
z-index: 10;
|
`
|
emptyStateDiv.innerHTML = `<span>暂无数据</span>`
|
if (
|
chartRef.value.style.position !== 'relative' &&
|
chartRef.value.style.position !== 'absolute'
|
) {
|
chartRef.value.style.position = 'relative'
|
}
|
chartRef.value.appendChild(emptyStateDiv)
|
},
|
remove: () => {
|
if (emptyStateDiv && chartRef.value) {
|
chartRef.value.removeChild(emptyStateDiv)
|
emptyStateDiv = null
|
}
|
},
|
updateStyle: () => {
|
if (emptyStateDiv) {
|
emptyStateDiv.style.color = isDark.value ? '#666' : '#999'
|
}
|
}
|
}
|
const initChart = (options2 = {}, isEmpty = false) => {
|
if (!chartRef.value || isDestroyed) return
|
const mergedOptions = { ...initOptions, ...options2 }
|
try {
|
if (isEmpty) {
|
if (chart) {
|
chart.clear()
|
}
|
emptyStateManager.create()
|
return
|
} else {
|
emptyStateManager.remove()
|
}
|
if (isContainerVisible(chartRef.value)) {
|
if (initDelay > 0) {
|
setTimeout(() => performChartInit(mergedOptions), initDelay)
|
} else {
|
performChartInit(mergedOptions)
|
}
|
} else {
|
pendingOptions = mergedOptions
|
createIntersectionObserver()
|
}
|
} catch (error) {
|
console.error('图表初始化失败:', error)
|
}
|
}
|
const updateChart = (options2) => {
|
if (isDestroyed) return
|
try {
|
if (!chart) {
|
initChart(options2)
|
return
|
}
|
chart.setOption(options2)
|
} catch (error) {
|
console.error('图表更新失败:', error)
|
}
|
}
|
const handleResize = () => {
|
if (chart && !isDestroyed) {
|
try {
|
chart.resize()
|
} catch (error) {
|
console.error('图表resize失败:', error)
|
}
|
}
|
}
|
const destroyChart = () => {
|
isDestroyed = true
|
if (chart) {
|
try {
|
chart.dispose()
|
} catch (error) {
|
console.error('图表销毁失败:', error)
|
} finally {
|
chart = null
|
}
|
}
|
cleanupMenuWatchers()
|
cleanupThemeWatcher()
|
emptyStateManager.remove()
|
cleanupIntersectionObserver()
|
clearTimers()
|
clearStyleCache()
|
pendingOptions = null
|
}
|
const getChartInstance = () => chart
|
const isChartInitialized = () => chart !== null
|
onMounted(() => {
|
window.addEventListener('resize', debouncedResize)
|
})
|
onBeforeUnmount(() => {
|
window.removeEventListener('resize', debouncedResize)
|
})
|
onUnmounted(() => {
|
destroyChart()
|
})
|
return {
|
isDark,
|
chartRef,
|
initChart,
|
updateChart,
|
handleResize,
|
destroyChart,
|
getChartInstance,
|
isChartInitialized,
|
emptyStateManager,
|
getAxisLineStyle,
|
getSplitLineStyle,
|
getAxisLabelStyle,
|
getAxisTickStyle,
|
getAnimationConfig,
|
getTooltipStyle,
|
getLegendStyle,
|
useChartOps,
|
getGridWithLegend
|
}
|
}
|
function useChartComponent(options) {
|
const {
|
props,
|
generateOptions,
|
checkEmpty,
|
watchSources = [],
|
onVisible,
|
chartOptions = {}
|
} = options
|
const chart = useChart(chartOptions)
|
const { chartRef, initChart, isDark, emptyStateManager } = chart
|
const isEmpty = computed(() => {
|
if (props.isEmpty) return true
|
if (checkEmpty) return checkEmpty()
|
return false
|
})
|
const updateChart = () => {
|
nextTick(() => {
|
if (isEmpty.value) {
|
if (chart.getChartInstance()) {
|
chart.getChartInstance()?.clear()
|
}
|
emptyStateManager.create()
|
} else {
|
emptyStateManager.remove()
|
initChart(generateOptions())
|
}
|
})
|
}
|
const handleChartVisible = () => {
|
if (onVisible) {
|
onVisible()
|
} else {
|
updateChart()
|
}
|
}
|
const stopHandles = []
|
const setupWatchers = () => {
|
if (watchSources.length > 0) {
|
const stopHandle = watch(watchSources, updateChart, { deep: true })
|
stopHandles.push(stopHandle)
|
}
|
const themeStopHandle = watch(isDark, () => {
|
emptyStateManager.updateStyle()
|
updateChart()
|
})
|
stopHandles.push(themeStopHandle)
|
}
|
const cleanupWatchers = () => {
|
stopHandles.forEach((stop) => stop())
|
stopHandles.length = 0
|
}
|
const setupLifecycle = () => {
|
onMounted(() => {
|
updateChart()
|
if (chartRef.value) {
|
chartRef.value.addEventListener('chartVisible', handleChartVisible)
|
}
|
})
|
onBeforeUnmount(() => {
|
if (chartRef.value) {
|
chartRef.value.removeEventListener('chartVisible', handleChartVisible)
|
}
|
cleanupWatchers()
|
emptyStateManager.remove()
|
})
|
}
|
setupWatchers()
|
setupLifecycle()
|
return {
|
...chart,
|
isEmpty,
|
updateChart,
|
handleChartVisible
|
}
|
}
|
export { useChart, useChartComponent, useChartOps }
|