383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
/**
|
||
* useTrendChart
|
||
*
|
||
* Composable that manages a combined bar+line ECharts trend chart.
|
||
* Features:
|
||
* - Tree-shakeable ECharts import (core + only used chart types/components)
|
||
* - Auto resize via ResizeObserver
|
||
* - DataZoom slider for 30+ day ranges
|
||
* - Day-over-day delta in tooltips
|
||
* - Average markLine
|
||
* - Period summary callback
|
||
* - Proper cleanup on unmount
|
||
*/
|
||
import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
|
||
import { init, use, graphic } from 'echarts/core'
|
||
import { BarChart, LineChart } from 'echarts/charts'
|
||
import {
|
||
TooltipComponent,
|
||
GridComponent,
|
||
LegendComponent,
|
||
DataZoomSliderComponent,
|
||
MarkLineComponent,
|
||
} from 'echarts/components'
|
||
import { CanvasRenderer } from 'echarts/renderers'
|
||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||
|
||
// Register only the features we actually use
|
||
use([
|
||
BarChart, LineChart,
|
||
TooltipComponent, GridComponent, LegendComponent,
|
||
DataZoomSliderComponent, MarkLineComponent,
|
||
CanvasRenderer,
|
||
])
|
||
|
||
const { LinearGradient } = graphic
|
||
|
||
export interface TrendDataPoint {
|
||
date: string
|
||
searches: number
|
||
saves: number
|
||
searchDelta: number
|
||
saveDelta: number
|
||
}
|
||
|
||
export interface TrendSummary {
|
||
totalSearches: number
|
||
totalSaves: number
|
||
avgSearches: number
|
||
avgSaves: number
|
||
peakDay: string
|
||
peakSearches: number
|
||
peakSaves: number
|
||
dayCount: number
|
||
}
|
||
|
||
export function useTrendChart(
|
||
trendData: Ref<TrendDataPoint[]>,
|
||
onSummary?: Ref<TrendSummary | null> | ((s: TrendSummary) => void),
|
||
) {
|
||
const chartRef = ref<HTMLDivElement | null>(null)
|
||
let chartInstance: ECharts | null = null
|
||
let resizeObserver: ResizeObserver | null = null
|
||
|
||
const BAR_WIDTH_PCT = '35%'
|
||
const BAR_GAP_PCT = '30%'
|
||
|
||
function computeSummary(data: TrendDataPoint[]): TrendSummary {
|
||
const totalSearches = data.reduce((s, d) => s + d.searches, 0)
|
||
const totalSaves = data.reduce((s, d) => s + d.saves, 0)
|
||
const n = data.length || 1
|
||
let peakIdx = 0
|
||
let peakVal = 0
|
||
data.forEach((d, i) => {
|
||
const v = d.searches + d.saves
|
||
if (v > peakVal) { peakVal = v; peakIdx = i }
|
||
})
|
||
return {
|
||
totalSearches,
|
||
totalSaves,
|
||
avgSearches: Math.round(totalSearches / n),
|
||
avgSaves: Math.round(totalSaves / n),
|
||
peakDay: data[peakIdx]?.date?.slice(5) || '—',
|
||
peakSearches: data[peakIdx]?.searches || 0,
|
||
peakSaves: data[peakIdx]?.saves || 0,
|
||
dayCount: n,
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
const el = chartRef.value
|
||
const data = trendData.value
|
||
if (!el) return
|
||
|
||
// Compute summary and emit
|
||
const summary = computeSummary(data)
|
||
if (typeof onSummary === 'function') {
|
||
onSummary(summary)
|
||
} else if (onSummary) {
|
||
onSummary.value = summary
|
||
}
|
||
|
||
// Empty state
|
||
if (!data.length) {
|
||
if (chartInstance) {
|
||
chartInstance.dispose()
|
||
chartInstance = null
|
||
}
|
||
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#909399;font-size:13px;">暂无使用数据</div>'
|
||
return
|
||
}
|
||
|
||
// Handle DOM element recreation (v-if remounts chart div when navigating away & back)
|
||
if (!chartInstance || chartInstance.getDom() !== el) {
|
||
if (chartInstance) chartInstance.dispose()
|
||
chartInstance = init(el)
|
||
initResize()
|
||
}
|
||
|
||
const dates = data.map(d => d.date.slice(5)) // MM-DD
|
||
const n = data.length
|
||
const maxCount = Math.max(...data.map(d => Math.max(d.searches, d.saves)), 1)
|
||
const yMax = Math.ceil(maxCount * 1.35) || 1
|
||
const avgSearches = Math.round(summary.totalSearches / n)
|
||
const showDataZoom = n >= 30
|
||
|
||
const option: EChartsCoreOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
backgroundColor: 'rgba(255,255,255,0.97)',
|
||
borderColor: '#e8e8e8',
|
||
borderWidth: 1,
|
||
borderRadius: 8,
|
||
padding: [10, 14],
|
||
textStyle: { fontSize: 12, color: '#303133' },
|
||
formatter: (params: any) => {
|
||
const idx = params[0]?.dataIndex ?? 0
|
||
const dateLabel = data[idx]?.date || ''
|
||
const d = data[idx]
|
||
const seen = new Set<string>()
|
||
const items = params.filter((p: any) => {
|
||
const key = p.seriesName
|
||
if (seen.has(key)) return false
|
||
seen.add(key)
|
||
return true
|
||
})
|
||
let html = `<div style="font-weight:700;font-size:13px;margin-bottom:8px;color:#1a1a2e">${dateLabel}</div>`
|
||
items.forEach((p: any) => {
|
||
const rawVal = p.value
|
||
const val = Array.isArray(rawVal) ? rawVal[1] : rawVal
|
||
const isBar = p.seriesType === 'bar'
|
||
const isSearch = p.seriesName === '搜索'
|
||
const delta = isSearch ? d?.searchDelta : d?.saveDelta
|
||
const deltaStr = delta !== undefined && delta !== 0
|
||
? `<span style="margin-left:6px;font-size:11px;color:${delta > 0 ? '#f56c6c' : '#67c23a'}">${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}</span>`
|
||
: (delta === 0 ? '<span style="margin-left:6px;font-size:11px;color:#909399">→0</span>' : '')
|
||
const icon = isBar
|
||
? `<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color};vertical-align:middle"></span>`
|
||
: `<span style="display:inline-block;width:14px;height:2px;background:${p.color};vertical-align:middle"></span>`
|
||
html += `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">${icon}<span>${p.seriesName}:<b>${val}</b> 次${deltaStr}</span></div>`
|
||
})
|
||
return html
|
||
},
|
||
},
|
||
legend: {
|
||
data: ['搜索', '保存'],
|
||
bottom: showDataZoom ? 30 : 0,
|
||
left: 'center',
|
||
itemWidth: 14,
|
||
itemHeight: 10,
|
||
textStyle: { fontSize: 11, color: '#666' },
|
||
},
|
||
grid: {
|
||
left: 8,
|
||
right: 12,
|
||
top: 28,
|
||
bottom: showDataZoom ? 70 : 42,
|
||
containLabel: true,
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
data: dates,
|
||
axisLabel: {
|
||
interval: 0,
|
||
fontSize: 11,
|
||
color: '#909399',
|
||
rotate: n > 15 ? 45 : 0,
|
||
},
|
||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||
splitLine: { show: false },
|
||
axisTick: { show: false },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
name: '次',
|
||
nameTextStyle: { fontSize: 10, color: '#909399' },
|
||
min: 0,
|
||
max: yMax,
|
||
splitNumber: 4,
|
||
axisLabel: {
|
||
fontSize: 10, color: '#909399' },
|
||
splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } },
|
||
},
|
||
series: [
|
||
// Bar — 搜索
|
||
{
|
||
name: '搜索',
|
||
type: 'bar',
|
||
data: data.map(d => d.searches),
|
||
barWidth: BAR_WIDTH_PCT,
|
||
barGap: BAR_GAP_PCT,
|
||
itemStyle: {
|
||
color: new LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: '#6366f1' },
|
||
{ offset: 1, color: '#a5b4fc' },
|
||
]),
|
||
borderRadius: [4, 4, 0, 0],
|
||
},
|
||
emphasis: {
|
||
itemStyle: {
|
||
color: new LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: '#4f46e5' },
|
||
{ offset: 1, color: '#818cf8' },
|
||
]),
|
||
},
|
||
},
|
||
animationDuration: 500,
|
||
animationEasing: 'cubicOut',
|
||
},
|
||
// Bar — 保存
|
||
{
|
||
name: '保存',
|
||
type: 'bar',
|
||
data: data.map(d => d.saves),
|
||
barWidth: BAR_WIDTH_PCT,
|
||
barGap: BAR_GAP_PCT,
|
||
itemStyle: {
|
||
color: new LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: '#10b981' },
|
||
{ offset: 1, color: '#6ee7b7' },
|
||
]),
|
||
borderRadius: [4, 4, 0, 0],
|
||
},
|
||
emphasis: {
|
||
itemStyle: {
|
||
color: new LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: '#059669' },
|
||
{ offset: 1, color: '#34d399' },
|
||
]),
|
||
},
|
||
},
|
||
animationDuration: 500,
|
||
animationEasing: 'cubicOut',
|
||
},
|
||
// Line — 搜索
|
||
{
|
||
name: '搜索',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 5,
|
||
data: data.map(d => d.searches),
|
||
lineStyle: { width: 2.5, color: '#4f46e5' },
|
||
itemStyle: { color: '#4f46e5', borderColor: '#fff', borderWidth: 2 },
|
||
areaStyle: {
|
||
color: new LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
|
||
{ offset: 1, color: 'rgba(99,102,241,0.01)' },
|
||
]),
|
||
},
|
||
label: {
|
||
show: false,
|
||
},
|
||
connectNulls: true,
|
||
animationDuration: 700,
|
||
animationEasing: 'cubicOut',
|
||
z: 3,
|
||
markLine: avgSearches > 0 ? {
|
||
silent: true,
|
||
symbol: 'none',
|
||
lineStyle: { color: '#6366f1', type: 'dashed', width: 1, opacity: 0.5 },
|
||
label: {
|
||
formatter: `均 ${avgSearches}`,
|
||
position: 'insideEndTop',
|
||
fontSize: 10,
|
||
color: '#6366f1',
|
||
},
|
||
data: [{ yAxis: avgSearches, name: '日均搜索' }],
|
||
} : undefined,
|
||
},
|
||
// Line — 保存
|
||
{
|
||
name: '保存',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 5,
|
||
data: data.map(d => d.saves),
|
||
lineStyle: { width: 2.5, color: '#059669' },
|
||
itemStyle: { color: '#059669', borderColor: '#fff', borderWidth: 2 },
|
||
areaStyle: {
|
||
color: new LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(16,185,129,0.12)' },
|
||
{ offset: 1, color: 'rgba(16,185,129,0.01)' },
|
||
]),
|
||
},
|
||
label: {
|
||
show: false,
|
||
},
|
||
connectNulls: true,
|
||
animationDuration: 700,
|
||
animationEasing: 'cubicOut',
|
||
z: 3,
|
||
markLine: avgSearches > 0 ? {
|
||
silent: true,
|
||
symbol: 'none',
|
||
lineStyle: { color: '#10b981', type: 'dashed', width: 1, opacity: 0.5 },
|
||
label: {
|
||
formatter: `均 ${Math.round(summary.totalSaves / n)}`,
|
||
position: 'insideEndTop',
|
||
fontSize: 10,
|
||
color: '#10b981',
|
||
},
|
||
data: [{ yAxis: Math.round(summary.totalSaves / n), name: '日均保存' }],
|
||
} : undefined,
|
||
},
|
||
],
|
||
// DataZoom slider for 30+ day views
|
||
...(showDataZoom ? {
|
||
dataZoom: [{
|
||
type: 'slider',
|
||
bottom: 6,
|
||
height: 20,
|
||
start: 0,
|
||
end: 100,
|
||
borderColor: '#e8e8e8',
|
||
fillerColor: 'rgba(99,102,241,0.08)',
|
||
handleStyle: { color: '#6366f1', borderColor: '#6366f1' },
|
||
textStyle: { fontSize: 10, color: '#909399' },
|
||
}],
|
||
} : {}),
|
||
}
|
||
|
||
chartInstance.setOption(option, true)
|
||
}
|
||
|
||
// Auto-render when data changes
|
||
watch(
|
||
trendData,
|
||
() => {
|
||
nextTick(() => render())
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
// Setup ResizeObserver for responsive chart
|
||
function initResize() {
|
||
if (!chartRef.value) return
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect()
|
||
resizeObserver = null
|
||
}
|
||
resizeObserver = new ResizeObserver(() => {
|
||
chartInstance?.resize()
|
||
})
|
||
resizeObserver.observe(chartRef.value)
|
||
}
|
||
|
||
// Cleanup
|
||
onUnmounted(() => {
|
||
resizeObserver?.disconnect()
|
||
resizeObserver = null
|
||
chartInstance?.dispose()
|
||
chartInstance = null
|
||
})
|
||
|
||
return {
|
||
chartRef,
|
||
render,
|
||
initResize,
|
||
}
|
||
}
|