diff --git a/src/views/DataBrowser/MetricDataModal.tsx b/src/views/DataBrowser/MetricDataModal.tsx index 74ef2bc2..6d9fe7db 100644 --- a/src/views/DataBrowser/MetricDataModal.tsx +++ b/src/views/DataBrowser/MetricDataModal.tsx @@ -31,7 +31,7 @@ import { } from '@chakra-ui/react'; import { FaTable, FaChartLine, FaCalendar, FaDownload } from 'react-icons/fa'; import { fetchMetricData, MetricDataResponse, TreeMetric } from '@services/categoryService'; -import KLineChartView from './KLineChartView'; +import SimpleLineChart from './SimpleLineChart'; // 黑金主题配色 const themeColors = { @@ -277,10 +277,10 @@ const MetricDataModal: React.FC = ({ isOpen, onClose, metr - {/* 折线图 - 使用 KLineChart */} + {/* 折线图 - 使用简单 Canvas 实现 */} {metricData && metricData.data.length > 0 ? ( - = ({ + 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;