Files
CloudSearch/source_clean/frontend-src/src/composables/useTrendChart.ts

383 lines
12 KiB
TypeScript
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.
/**
* 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,
}
}