189 lines
4.9 KiB
TypeScript
189 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
LineController,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
ChartData,
|
|
ChartOptions,
|
|
TooltipItem
|
|
} from 'chart.js';
|
|
import { TimeSeriesData } from '@/app/api/types';
|
|
|
|
// 注册 Chart.js 组件
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
LineController,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
);
|
|
|
|
interface TimeSeriesChartProps {
|
|
data: TimeSeriesData[];
|
|
}
|
|
|
|
export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|
const chartRef = useRef<HTMLCanvasElement | null>(null);
|
|
const chartInstance = useRef<ChartJS | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!chartRef.current) return;
|
|
|
|
// 销毁旧的图表实例
|
|
if (chartInstance.current) {
|
|
chartInstance.current.destroy();
|
|
}
|
|
|
|
const ctx = chartRef.current.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// 准备数据
|
|
const labels = data.map(item => {
|
|
if (!item || !item.timestamp) return '';
|
|
const date = new Date(item.timestamp);
|
|
return date.toLocaleDateString();
|
|
});
|
|
|
|
const eventsData = data.map(item => {
|
|
if (!item || item.events === undefined || item.events === null) return 0;
|
|
return Number(item.events);
|
|
});
|
|
|
|
const visitorsData = data.map(item => {
|
|
if (!item || item.visitors === undefined || item.visitors === null) return 0;
|
|
return Number(item.visitors);
|
|
});
|
|
|
|
const conversionsData = data.map(item => {
|
|
if (!item || item.conversions === undefined || item.conversions === null) return 0;
|
|
return Number(item.conversions);
|
|
});
|
|
|
|
// 创建新的图表实例
|
|
chartInstance.current = new ChartJS(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'Events',
|
|
data: eventsData,
|
|
borderColor: 'rgb(59, 130, 246)', // blue-500
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
},
|
|
{
|
|
label: 'Visitors',
|
|
data: visitorsData,
|
|
borderColor: 'rgb(16, 185, 129)', // green-500
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
},
|
|
{
|
|
label: 'Conversions',
|
|
data: conversionsData,
|
|
borderColor: 'rgb(239, 68, 68)', // red-500
|
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
}
|
|
]
|
|
} as ChartData<'line'>,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
mode: 'index',
|
|
intersect: false
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: {
|
|
usePointStyle: true,
|
|
padding: 20,
|
|
color: 'rgb(156, 163, 175)' // gray-400
|
|
}
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
backgroundColor: 'rgb(31, 41, 55)', // gray-800
|
|
titleColor: 'rgb(229, 231, 235)', // gray-200
|
|
bodyColor: 'rgb(229, 231, 235)', // gray-200
|
|
borderColor: 'rgb(75, 85, 99)', // gray-600
|
|
borderWidth: 1,
|
|
padding: 12,
|
|
displayColors: true,
|
|
callbacks: {
|
|
title: (items: TooltipItem<'line'>[]) => {
|
|
if (items.length > 0) {
|
|
const date = new Date(data[items[0].dataIndex].timestamp);
|
|
return date.toLocaleDateString();
|
|
}
|
|
return '';
|
|
},
|
|
label: (context) => {
|
|
const label = context.dataset.label || '';
|
|
const value = context.parsed.y;
|
|
return `${label}: ${Math.round(value)}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
color: 'rgb(156, 163, 175)' // gray-400
|
|
}
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: {
|
|
color: 'rgb(75, 85, 99, 0.1)' // gray-600 with opacity
|
|
},
|
|
ticks: {
|
|
color: 'rgb(156, 163, 175)', // gray-400
|
|
callback: (value: number) => {
|
|
if (!value && value !== 0) return '';
|
|
if (value >= 1000) {
|
|
return `${Math.round(value / 1000)}k`;
|
|
}
|
|
return Math.round(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} as ChartOptions<'line'>
|
|
});
|
|
|
|
// 清理函数
|
|
return () => {
|
|
if (chartInstance.current) {
|
|
chartInstance.current.destroy();
|
|
}
|
|
};
|
|
}, [data]);
|
|
|
|
return (
|
|
<canvas ref={chartRef} />
|
|
);
|
|
}
|