import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Box, VStack, HStack, Text, Button, ButtonGroup, Flex, Icon, Tooltip, } from '@chakra-ui/react'; import { Maximize, Minimize, Camera, RotateCcw, } from 'lucide-react'; import { MetricDataPoint } from '@services/categoryService'; // 黑金主题配色 const themeColors = { bg: { primary: '#0a0a0a', secondary: '#1a1a1a', card: '#1e1e1e', }, text: { primary: '#ffffff', secondary: '#b8b8b8', muted: '#808080', gold: '#D4AF37', }, border: { default: 'rgba(255, 255, 255, 0.1)', gold: 'rgba(212, 175, 55, 0.3)', }, primary: { gold: '#D4AF37', goldLight: '#F4E3A7', }, }; interface SimpleLineChartProps { data: MetricDataPoint[]; metricName: string; unit: string; frequency: string; } type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL'; const SimpleLineChart: React.FC = ({ data, metricName, unit, frequency, }) => { const canvasRef = useRef(null); const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); const [selectedRange, setSelectedRange] = useState('ALL'); const [hoveredPoint, setHoveredPoint] = useState<{ x: number; y: number; data: MetricDataPoint } | null>(null); // 过滤并排序数据 const processedData = useMemo(() => { const filtered = data .filter((item) => item.value !== null) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); // 根据时间范围筛选 if (selectedRange === 'ALL' || filtered.length === 0) { return filtered; } const now = new Date(); let startDate: Date; switch (selectedRange) { case '1M': startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); break; case '3M': startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()); break; case '6M': startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate()); break; case '1Y': startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); break; case 'YTD': startDate = new Date(now.getFullYear(), 0, 1); break; default: return filtered; } return filtered.filter((item) => new Date(item.date) >= startDate); }, [data, selectedRange]); // 计算统计数据 const stats = useMemo(() => { const values = processedData.map((item) => item.value as number); if (values.length === 0) { return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 }; } const min = Math.min(...values); const max = Math.max(...values); const avg = values.reduce((sum, val) => sum + val, 0) / values.length; const latest = values[values.length - 1]; const first = values[0]; const change = latest - first; const changePercent = first !== 0 ? (change / first) * 100 : 0; return { min, max, avg, latest, change, changePercent }; }, [processedData]); // 绘制图表 useEffect(() => { if (!canvasRef.current || processedData.length === 0) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; // 设置 canvas 尺寸(高分辨率) const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const width = rect.width; const height = rect.height; const padding = { top: 30, right: 60, bottom: 60, left: 60 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; // 清空画布 ctx.fillStyle = themeColors.bg.card; ctx.fillRect(0, 0, width, height); // 获取数据范围 const values = processedData.map((item) => item.value as number); const minValue = Math.min(...values); const maxValue = Math.max(...values); const valueRange = maxValue - minValue || 1; // 绘制网格线 ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; ctx.lineWidth = 1; // 横向网格线(5条) for (let i = 0; i <= 5; i++) { const y = padding.top + (chartHeight * i) / 5; ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(padding.left + chartWidth, y); ctx.stroke(); // Y轴刻度标签 const value = maxValue - (valueRange * i) / 5; ctx.fillStyle = themeColors.text.secondary; ctx.font = '11px sans-serif'; ctx.textAlign = 'right'; ctx.fillText(value.toFixed(2), padding.left - 10, y + 4); } // 纵向网格线(根据数据点数量决定) const xAxisSteps = Math.min(10, processedData.length); for (let i = 0; i <= xAxisSteps; i++) { const x = padding.left + (chartWidth * i) / xAxisSteps; ctx.beginPath(); ctx.moveTo(x, padding.top); ctx.lineTo(x, padding.top + chartHeight); ctx.stroke(); // X轴日期标签(只显示部分) if (i % Math.ceil(xAxisSteps / 5) === 0 || i === xAxisSteps) { const dataIndex = Math.floor((processedData.length - 1) * (i / xAxisSteps)); const date = processedData[dataIndex]?.date || ''; ctx.fillStyle = themeColors.text.secondary; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.save(); ctx.translate(x, padding.top + chartHeight + 15); ctx.rotate(-Math.PI / 6); ctx.fillText(date, 0, 0); ctx.restore(); } } // 绘制折线 ctx.strokeStyle = themeColors.primary.gold; ctx.lineWidth = 2; ctx.beginPath(); processedData.forEach((item, index) => { const x = padding.left + (chartWidth * index) / (processedData.length - 1 || 1); const y = padding.top + chartHeight - ((item.value as number - minValue) / valueRange) * chartHeight; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // 绘制数据点 ctx.fillStyle = themeColors.primary.gold; processedData.forEach((item, index) => { const x = padding.left + (chartWidth * index) / (processedData.length - 1 || 1); const y = padding.top + chartHeight - ((item.value as number - minValue) / valueRange) * chartHeight; ctx.beginPath(); ctx.arc(x, y, 3, 0, 2 * Math.PI); ctx.fill(); }); // 绘制悬停点 if (hoveredPoint) { ctx.strokeStyle = themeColors.primary.goldLight; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); // 垂直线 ctx.beginPath(); ctx.moveTo(hoveredPoint.x, padding.top); ctx.lineTo(hoveredPoint.x, padding.top + chartHeight); ctx.stroke(); // 水平线 ctx.beginPath(); ctx.moveTo(padding.left, hoveredPoint.y); ctx.lineTo(padding.left + chartWidth, hoveredPoint.y); ctx.stroke(); ctx.setLineDash([]); // 高亮点 ctx.fillStyle = themeColors.primary.goldLight; ctx.beginPath(); ctx.arc(hoveredPoint.x, hoveredPoint.y, 5, 0, 2 * Math.PI); ctx.fill(); } }, [processedData, hoveredPoint]); // 鼠标移动事件 const handleMouseMove = (e: React.MouseEvent) => { if (!canvasRef.current || processedData.length === 0) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const padding = { top: 30, right: 60, bottom: 60, left: 60 }; const chartWidth = rect.width - padding.left - padding.right; const chartHeight = rect.height - padding.top - padding.bottom; // 判断是否在图表区域内 if ( x >= padding.left && x <= padding.left + chartWidth && y >= padding.top && y <= padding.top + chartHeight ) { // 找到最近的数据点 const relativeX = x - padding.left; const index = Math.round((relativeX / chartWidth) * (processedData.length - 1)); const dataPoint = processedData[index]; if (dataPoint) { const values = processedData.map((item) => item.value as number); const minValue = Math.min(...values); const maxValue = Math.max(...values); const valueRange = maxValue - minValue || 1; const pointX = padding.left + (chartWidth * index) / (processedData.length - 1 || 1); const pointY = padding.top + chartHeight - ((dataPoint.value as number - minValue) / valueRange) * chartHeight; setHoveredPoint({ x: pointX, y: pointY, data: dataPoint }); } } else { setHoveredPoint(null); } }; const handleMouseLeave = () => { setHoveredPoint(null); }; // 时间范围切换 const handleTimeRangeChange = (range: TimeRange) => { setSelectedRange(range); }; // 重置 const handleReset = () => { setSelectedRange('ALL'); }; // 截图 const handleScreenshot = () => { if (!canvasRef.current) return; canvasRef.current.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`; link.click(); URL.revokeObjectURL(url); }); }; // 全屏 const toggleFullscreen = () => { if (!containerRef.current) return; if (!isFullscreen) { if (containerRef.current.requestFullscreen) { containerRef.current.requestFullscreen(); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } } setIsFullscreen(!isFullscreen); }; // 格式化数字 const formatNumber = (num: number) => { if (Math.abs(num) >= 1e9) { return (num / 1e9).toFixed(2) + 'B'; } if (Math.abs(num) >= 1e6) { return (num / 1e6).toFixed(2) + 'M'; } if (Math.abs(num) >= 1e3) { return (num / 1e3).toFixed(2) + 'K'; } return num.toFixed(2); }; return ( {/* 工具栏 */} {/* 时间范围选择 */} {(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => ( ))} {/* 图表操作 */} {/* 统计数据 */} 最新值 {formatNumber(stats.latest)} {unit} = 0 ? '#00ff88' : '#ff4444'} fontSize="xs" fontWeight="bold" > {stats.change >= 0 ? '+' : ''} {formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%) 平均值 {formatNumber(stats.avg)} {unit} 最高值 {formatNumber(stats.max)} {unit} 最低值 {formatNumber(stats.min)} {unit} 数据点数 {processedData.length} 频率 {frequency} {/* 图表容器 */} {/* 悬停提示 */} {hoveredPoint && ( {hoveredPoint.data.date} {hoveredPoint.data.value} {unit} )} {/* 提示信息 */} 💡 提示:鼠标悬停查看详细数据 数据来源: {metricName} ); }; export default SimpleLineChart;