509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
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<TradingViewChartProps> = ({
|
||
data,
|
||
metricName,
|
||
unit,
|
||
frequency,
|
||
}) => {
|
||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||
const chartRef = useRef<IChartApi | null>(null);
|
||
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
const [selectedRange, setSelectedRange] = useState<TimeRange>('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 (
|
||
<VStack align="stretch" spacing={4} w="100%">
|
||
{/* 工具栏 */}
|
||
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
|
||
{/* 时间范围选择 */}
|
||
<ButtonGroup size="sm" isAttached variant="outline">
|
||
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
|
||
<Button
|
||
key={range}
|
||
onClick={() => handleTimeRangeChange(range)}
|
||
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
|
||
color={
|
||
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
|
||
}
|
||
borderColor={themeColors.border.gold}
|
||
_hover={{
|
||
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
|
||
}}
|
||
>
|
||
{range}
|
||
</Button>
|
||
))}
|
||
</ButtonGroup>
|
||
|
||
{/* 图表操作 */}
|
||
<HStack spacing={2}>
|
||
<Tooltip label="重置视图">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
color={themeColors.text.secondary}
|
||
_hover={{ color: themeColors.primary.gold }}
|
||
onClick={handleReset}
|
||
>
|
||
<Icon as={FaRedo} />
|
||
</Button>
|
||
</Tooltip>
|
||
<Tooltip label="截图">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
color={themeColors.text.secondary}
|
||
_hover={{ color: themeColors.primary.gold }}
|
||
onClick={handleScreenshot}
|
||
>
|
||
<Icon as={FaCamera} />
|
||
</Button>
|
||
</Tooltip>
|
||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
color={themeColors.text.secondary}
|
||
_hover={{ color: themeColors.primary.gold }}
|
||
onClick={toggleFullscreen}
|
||
>
|
||
<Icon as={isFullscreen ? FaCompress : FaExpand} />
|
||
</Button>
|
||
</Tooltip>
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 统计数据 */}
|
||
<Flex
|
||
justify="space-around"
|
||
align="center"
|
||
bg={themeColors.bg.secondary}
|
||
p={3}
|
||
borderRadius="md"
|
||
borderWidth="1px"
|
||
borderColor={themeColors.border.default}
|
||
wrap="wrap"
|
||
gap={4}
|
||
>
|
||
<VStack spacing={0}>
|
||
<Text color={themeColors.text.muted} fontSize="xs">
|
||
最新值
|
||
</Text>
|
||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
||
{formatNumber(stats.latest)} {unit}
|
||
</Text>
|
||
<Text
|
||
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
>
|
||
{stats.change >= 0 ? '+' : ''}
|
||
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack spacing={0}>
|
||
<Text color={themeColors.text.muted} fontSize="xs">
|
||
平均值
|
||
</Text>
|
||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||
{formatNumber(stats.avg)} {unit}
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack spacing={0}>
|
||
<Text color={themeColors.text.muted} fontSize="xs">
|
||
最高值
|
||
</Text>
|
||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||
{formatNumber(stats.max)} {unit}
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack spacing={0}>
|
||
<Text color={themeColors.text.muted} fontSize="xs">
|
||
最低值
|
||
</Text>
|
||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||
{formatNumber(stats.min)} {unit}
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack spacing={0}>
|
||
<Text color={themeColors.text.muted} fontSize="xs">
|
||
数据点数
|
||
</Text>
|
||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||
{data.filter((item) => item.value !== null).length}
|
||
</Text>
|
||
</VStack>
|
||
|
||
<VStack spacing={0}>
|
||
<Text color={themeColors.text.muted} fontSize="xs">
|
||
频率
|
||
</Text>
|
||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||
{frequency}
|
||
</Text>
|
||
</VStack>
|
||
</Flex>
|
||
|
||
{/* 图表容器 */}
|
||
<Box
|
||
ref={chartContainerRef}
|
||
w="100%"
|
||
h="500px"
|
||
borderRadius="md"
|
||
borderWidth="1px"
|
||
borderColor={themeColors.border.gold}
|
||
overflow="hidden"
|
||
position="relative"
|
||
bg={themeColors.bg.card}
|
||
/>
|
||
|
||
{/* 提示信息 */}
|
||
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
|
||
<HStack spacing={4}>
|
||
<Text>💡 提示:滚动鼠标滚轮缩放,拖拽移动视图</Text>
|
||
</HStack>
|
||
<Text>数据来源: {metricName}</Text>
|
||
</Flex>
|
||
</VStack>
|
||
);
|
||
};
|
||
|
||
export default TradingViewChart;
|