Files
vf_react/src/views/DataBrowser/SimpleLineChart.tsx
zdl 2207a680b5 refactor(icons): 迁移其他 views 目录图标到 lucide-react
- views/Center, views/Community, views/DataBrowser 等
- views/EventDetail, views/LimitAnalyse, views/StockOverview
- views/TradingSimulation, views/Pages, views/Authentication
- views/Profile, views/Settings
- 处理 Tag/TagIcon 命名冲突
- 涉及 52 个组件文件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 13:00:41 +08:00

545 lines
16 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, useMemo } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
ButtonGroup,
Flex,
Icon,
Tooltip,
} from '@chakra-ui/react';
import {
Maximize,
Minimize,
Camera,
RotateCcw,
} from 'lucide-react';
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 SimpleLineChartProps {
data: MetricDataPoint[];
metricName: string;
unit: string;
frequency: string;
}
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
const SimpleLineChart: React.FC<SimpleLineChartProps> = ({
data,
metricName,
unit,
frequency,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedRange, setSelectedRange] = useState<TimeRange>('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<HTMLCanvasElement>) => {
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 (
<VStack align="stretch" spacing={4} w="100%" ref={containerRef}>
{/* 工具栏 */}
<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={RotateCcw} />
</Button>
</Tooltip>
<Tooltip label="截图">
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={handleScreenshot}
>
<Icon as={Camera} />
</Button>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={toggleFullscreen}
>
<Icon as={isFullscreen ? Minimize : Maximize} />
</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">
{processedData.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 position="relative">
<canvas
ref={canvasRef}
width={800}
height={500}
style={{
width: '100%',
height: '500px',
borderRadius: '6px',
border: `1px solid ${themeColors.border.gold}`,
backgroundColor: themeColors.bg.card,
cursor: 'crosshair',
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
{/* 悬停提示 */}
{hoveredPoint && (
<Box
position="absolute"
top={2}
left={2}
bg={themeColors.bg.secondary}
p={2}
borderRadius="md"
borderWidth="1px"
borderColor={themeColors.border.gold}
pointerEvents="none"
>
<Text color={themeColors.text.gold} fontSize="xs" fontWeight="bold">
{hoveredPoint.data.date}
</Text>
<Text color={themeColors.text.primary} fontSize="sm">
{hoveredPoint.data.value} {unit}
</Text>
</Box>
)}
</Box>
{/* 提示信息 */}
<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 SimpleLineChart;