/** * 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, onSummary?: Ref | ((s: TrendSummary) => void), ) { const chartRef = ref(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 = '
暂无使用数据
' 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() const items = params.filter((p: any) => { const key = p.seriesName if (seen.has(key)) return false seen.add(key) return true }) let html = `
${dateLabel}
` 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 ? `${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}` : (delta === 0 ? '→0' : '') const icon = isBar ? `` : `` html += `
${icon}${p.seriesName}:${val} 次${deltaStr}
` }) 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, } }