diff --git a/src/views/DataBrowser/MetricDataModal.tsx b/src/views/DataBrowser/MetricDataModal.tsx index 5afac629..06ec6cdd 100644 --- a/src/views/DataBrowser/MetricDataModal.tsx +++ b/src/views/DataBrowser/MetricDataModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { Modal, ModalOverlay, @@ -30,8 +30,8 @@ import { useToast, } from '@chakra-ui/react'; import { FaTable, FaChartLine, FaCalendar, FaDownload } from 'react-icons/fa'; -import ReactECharts from 'echarts-for-react'; import { fetchMetricData, MetricDataResponse, TreeMetric } from '@services/categoryService'; +import TradingViewChart from './TradingViewChart'; // 黑金主题配色 const themeColors = { @@ -98,131 +98,7 @@ const MetricDataModal: React.FC = ({ isOpen, onClose, metr } }; - // 准备图表数据 - const chartOption = useMemo(() => { - if (!metricData || !metricData.data || metricData.data.length === 0) { - return null; - } - - const dates = metricData.data.map((item) => item.date); - const values = metricData.data.map((item) => item.value); - - return { - backgroundColor: 'transparent', - title: { - text: metricData.metric_name, - left: 'center', - textStyle: { - color: themeColors.text.gold, - fontSize: 16, - fontWeight: 'bold', - }, - }, - tooltip: { - trigger: 'axis', - backgroundColor: themeColors.bg.card, - borderColor: themeColors.border.gold, - textStyle: { - color: themeColors.text.primary, - }, - formatter: (params: any) => { - const param = params[0]; - return ` -
-
- ${param.name} -
-
- ${param.seriesName}: ${param.value !== null ? param.value.toLocaleString() : '-'} ${metricData.unit || ''} -
-
- `; - }, - }, - grid: { - left: '3%', - right: '4%', - bottom: '10%', - top: '15%', - containLabel: true, - }, - xAxis: { - type: 'category', - data: dates, - axisLabel: { - color: themeColors.text.secondary, - rotate: 45, - fontSize: 10, - }, - axisLine: { - lineStyle: { - color: themeColors.border.default, - }, - }, - }, - yAxis: { - type: 'value', - name: metricData.unit || '', - nameTextStyle: { - color: themeColors.text.gold, - }, - axisLabel: { - color: themeColors.text.secondary, - formatter: (value: number) => value.toLocaleString(), - }, - splitLine: { - lineStyle: { - color: themeColors.border.default, - type: 'dashed', - }, - }, - axisLine: { - lineStyle: { - color: themeColors.border.default, - }, - }, - }, - series: [ - { - name: metricData.metric_name, - type: 'line', - smooth: true, - symbol: 'circle', - symbolSize: 6, - lineStyle: { - color: themeColors.primary.gold, - width: 2, - }, - itemStyle: { - color: themeColors.primary.gold, - borderColor: themeColors.primary.goldLight, - borderWidth: 2, - }, - areaStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [ - { - offset: 0, - color: 'rgba(212, 175, 55, 0.3)', - }, - { - offset: 1, - color: 'rgba(212, 175, 55, 0.05)', - }, - ], - }, - }, - data: values, - connectNulls: true, - }, - ], - }; - }, [metricData]); + // 数据已经在 metricData 中,直接传递给 TradingViewChart // 导出CSV const handleExportCSV = () => { @@ -401,24 +277,15 @@ const MetricDataModal: React.FC = ({ isOpen, onClose, metr - {/* 折线图 */} + {/* 折线图 - 使用 TradingView Lightweight Charts */} - {chartOption ? ( - - - - 共 {metricData.data.length} 条数据点 - - + {metricData && metricData.data.length > 0 ? ( + ) : ( 暂无数据 diff --git a/src/views/DataBrowser/TradingViewChart.tsx b/src/views/DataBrowser/TradingViewChart.tsx new file mode 100644 index 00000000..33b49185 --- /dev/null +++ b/src/views/DataBrowser/TradingViewChart.tsx @@ -0,0 +1,471 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Button, + ButtonGroup, + Flex, + Icon, + useColorMode, + Tooltip, +} from '@chakra-ui/react'; +import { createChart, 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; + + // 创建图表 + const chart = createChart(chartContainerRef.current, { + width: chartContainerRef.current.clientWidth, + height: 500, + layout: { + background: { color: themeColors.bg.card }, + textColor: themeColors.text.secondary, + }, + grid: { + vertLines: { + color: 'rgba(255, 255, 255, 0.05)', + style: 1, // 实线 + }, + horzLines: { + color: 'rgba(255, 255, 255, 0.05)', + style: 1, + }, + }, + crosshair: { + mode: 1, // 正常十字线模式 + 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, + scaleMargins: { + top: 0.1, + bottom: 0.1, + }, + }, + timeScale: { + borderColor: themeColors.border.default, + timeVisible: true, + secondsVisible: false, + rightOffset: 5, + barSpacing: 10, + minBarSpacing: 3, + fixLeftEdge: true, + fixRightEdge: true, + }, + handleScroll: { + mouseWheel: true, + pressedMouseMove: true, + horzTouchDrag: true, + vertTouchDrag: true, + }, + handleScale: { + axisPressedMouseMove: true, + mouseWheel: true, + pinch: true, + }, + }); + + // 创建折线系列 + const lineSeries = chart.addLineSeries({ + color: themeColors.primary.gold, + lineWidth: 2, + lineStyle: 0, // 实线 + 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, + }); + + // 转换数据格式 + const chartData: LineData[] = data + .filter((item) => item.value !== null) + .map((item) => ({ + time: item.date 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(); + }; + }, [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;