import React, { useEffect, useRef, useState } from 'react'; import { Box, VStack, HStack, Text, Button, ButtonGroup, Flex, Icon, useColorMode, Tooltip, } from '@chakra-ui/react'; import { createChart, LineSeries } from 'lightweight-charts'; import type { IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts'; import { FaExpand, FaCompress, FaCamera, FaRedo, FaCog, } from 'react-icons/fa'; 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 TradingViewChartProps { data: MetricDataPoint[]; metricName: string; unit: string; frequency: string; } type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL'; const TradingViewChart: React.FC = ({ data, metricName, unit, frequency, }) => { const chartContainerRef = useRef(null); const chartRef = useRef(null); const lineSeriesRef = useRef | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const [selectedRange, setSelectedRange] = useState('ALL'); const { colorMode } = useColorMode(); // 初始化图表 useEffect(() => { if (!chartContainerRef.current || data.length === 0) return; try { // 创建图表 (lightweight-charts 5.0 标准 API) const chart = createChart(chartContainerRef.current, { width: chartContainerRef.current.clientWidth, height: 500, layout: { background: { type: 'solid', color: themeColors.bg.card }, textColor: themeColors.text.secondary, }, grid: { vertLines: { color: 'rgba(255, 255, 255, 0.05)', }, horzLines: { color: 'rgba(255, 255, 255, 0.05)', }, }, crosshair: { vertLine: { color: themeColors.primary.gold, width: 1, style: 3, // 虚线 labelBackgroundColor: themeColors.primary.gold, }, horzLine: { color: themeColors.primary.gold, width: 1, style: 3, labelBackgroundColor: themeColors.primary.gold, }, }, rightPriceScale: { borderColor: themeColors.border.default, }, timeScale: { borderColor: themeColors.border.default, timeVisible: true, secondsVisible: false, rightOffset: 12, barSpacing: 6, // 增加条形间距,减少拥挤 fixLeftEdge: false, lockVisibleTimeRangeOnResize: true, rightBarStaysOnScroll: true, borderVisible: true, visible: true, // 控制时间标签的最小间距(像素) tickMarkMaxCharacterLength: 8, }, localization: { locale: 'en-US', // 使用 ISO 日期格式,强制显示 YYYY-MM-DD dateFormat: 'dd MMM \'yy', // 这会被我们的自定义格式化器覆盖 }, handleScroll: { mouseWheel: true, pressedMouseMove: true, }, handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true, }, }); // 设置时间轴的自定义格式化器(强制显示 YYYY-MM-DD) chart.applyOptions({ localization: { timeFormatter: (time) => { // time 可能是字符串 'YYYY-MM-DD' 或时间戳 if (typeof time === 'string') { return time; // 直接返回 YYYY-MM-DD 字符串 } // 如果是时间戳,转换为 YYYY-MM-DD const date = new Date(time * 1000); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }, }, }); // 创建折线系列 (lightweight-charts 5.0 使用 addSeries 方法) // 第一个参数是 series 类本身(不是实例) const lineSeries = chart.addSeries(LineSeries, { color: themeColors.primary.gold, lineWidth: 2, crosshairMarkerVisible: true, crosshairMarkerRadius: 6, crosshairMarkerBorderColor: themeColors.primary.goldLight, crosshairMarkerBackgroundColor: themeColors.primary.gold, lastValueVisible: true, priceLineVisible: true, priceLineColor: themeColors.primary.gold, priceLineWidth: 1, priceLineStyle: 3, // 虚线 title: metricName, }); // 转换数据格式 // lightweight-charts 5.0 需要 YYYY-MM-DD 格式的字符串作为 time const chartData: LineData[] = data .filter((item) => item.value !== null) .map((item) => { // 确保日期格式为 YYYY-MM-DD const dateStr = item.date.trim(); return { time: dateStr as Time, value: item.value as number, }; }) .sort((a, b) => { // 确保时间从左到右递增 const timeA = new Date(a.time as string).getTime(); const timeB = new Date(b.time as string).getTime(); return timeA - timeB; }); // 设置数据 lineSeries.setData(chartData); // 自动缩放到合适的视图 chart.timeScale().fitContent(); chartRef.current = chart; lineSeriesRef.current = lineSeries; // 响应式调整 const handleResize = () => { if (chartContainerRef.current && chart) { chart.applyOptions({ width: chartContainerRef.current.clientWidth, }); } }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); chart.remove(); }; } catch (error) { console.error('❌ TradingView Chart 初始化失败:', error); console.error('Error details:', { message: error.message, stack: error.stack, createChartType: typeof createChart, LineSeriesType: typeof LineSeries, }); // 重新抛出错误让 ErrorBoundary 捕获 throw error; } }, [data, metricName]); // 时间范围筛选 const handleTimeRangeChange = (range: TimeRange) => { setSelectedRange(range); if (!chartRef.current || data.length === 0) return; const now = new Date(); let startDate: Date; switch (range) { 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); // 当年1月1日 break; case 'ALL': default: chartRef.current.timeScale().fitContent(); return; } // 设置可见范围 const startTimestamp = startDate.getTime() / 1000; const endTimestamp = now.getTime() / 1000; chartRef.current.timeScale().setVisibleRange({ from: startTimestamp as Time, to: endTimestamp as Time, }); }; // 重置缩放 const handleReset = () => { if (chartRef.current) { chartRef.current.timeScale().fitContent(); setSelectedRange('ALL'); } }; // 截图功能 const handleScreenshot = () => { if (!chartRef.current) return; const canvas = chartContainerRef.current?.querySelector('canvas'); if (!canvas) return; canvas.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 (!chartContainerRef.current) return; if (!isFullscreen) { if (chartContainerRef.current.requestFullscreen) { chartContainerRef.current.requestFullscreen(); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } } setIsFullscreen(!isFullscreen); }; // 计算统计数据 const stats = React.useMemo(() => { const values = data.filter((item) => item.value !== null).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 }; }, [data]); // 格式化数字 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} 数据点数 {data.filter((item) => item.value !== null).length} 频率 {frequency} {/* 图表容器 */} {/* 提示信息 */} 💡 提示:滚动鼠标滚轮缩放,拖拽移动视图 数据来源: {metricName} ); }; export default TradingViewChart;