Files
vf_react/src/views/DataBrowser/KLineChartView.tsx
2025-11-26 10:11:02 +08:00

396 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef, useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
ButtonGroup,
Flex,
Icon,
Tooltip,
} from '@chakra-ui/react';
import { init, dispose } from 'klinecharts';
import {
FaExpand,
FaCompress,
FaCamera,
FaRedo,
} 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 KLineChartViewProps {
data: MetricDataPoint[];
metricName: string;
unit: string;
frequency: string;
}
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
const KLineChartView: React.FC<KLineChartViewProps> = ({
data,
metricName,
unit,
frequency,
}) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<any>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current || data.length === 0) return;
// 创建图表实例
const chart = init(chartContainerRef.current, {
styles: {
grid: {
horizontal: {
color: 'rgba(255, 255, 255, 0.05)',
},
vertical: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
candle: {
type: 'area' as const, // 使用面积图模式
area: {
lineColor: themeColors.primary.gold,
lineSize: 2,
backgroundColor: [`${themeColors.primary.gold}30`, `${themeColors.primary.gold}05`],
},
} as any,
crosshair: {
horizontal: {
line: {
color: themeColors.primary.gold,
style: 'dashed',
size: 1,
},
text: {
backgroundColor: themeColors.primary.gold,
color: themeColors.bg.primary,
},
},
vertical: {
line: {
color: themeColors.primary.gold,
style: 'dashed',
size: 1,
},
text: {
backgroundColor: themeColors.primary.gold,
color: themeColors.bg.primary,
},
},
},
xAxis: {
axisLine: {
color: themeColors.border.default,
},
tickLine: {
color: themeColors.border.default,
},
tickText: {
color: themeColors.text.secondary,
},
},
yAxis: {
axisLine: {
color: themeColors.border.default,
},
tickLine: {
color: themeColors.border.default,
},
tickText: {
color: themeColors.text.secondary,
},
},
},
});
// 转换数据格式
const chartData = data
.filter((item) => item.value !== null)
.map((item) => ({
timestamp: new Date(item.date).getTime(),
open: item.value,
high: item.value,
low: item.value,
close: item.value,
}))
.sort((a, b) => a.timestamp - b.timestamp);
// 设置数据
(chart as any)?.applyNewData(chartData);
chartRef.current = chart;
// 清理
return () => {
if (chartRef.current) {
dispose(chartContainerRef.current!);
chartRef.current = null;
}
};
}, [data]);
// 时间范围筛选
const handleTimeRangeChange = (range: TimeRange) => {
setSelectedRange(range);
// TODO: 实现时间范围筛选逻辑
};
// 重置缩放
const handleReset = () => {
chartRef.current?.zoomAtCoordinate({ x: 0.5 }, 1);
setSelectedRange('ALL');
};
// 截图功能
const handleScreenshot = () => {
if (!chartRef.current) return;
const imageUrl = chartRef.current.getConvertPictureUrl();
const link = document.createElement('a');
link.href = imageUrl;
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
link.click();
};
// 全屏切换
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 KLineChartView;