Files
vf_react/src/views/LimitAnalyse/components/DataVisualizationComponents.js

620 lines
24 KiB
JavaScript

import React, { useMemo, useState, useEffect } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Center,
VStack,
HStack,
Heading,
Text,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Stat,
StatLabel,
StatNumber,
StatHelpText,
SimpleGrid,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
Badge,
Divider,
Avatar,
Tag,
TagLabel,
Wrap,
WrapItem,
useColorModeValue,
} from '@chakra-ui/react';
import { getFormattedTextProps } from '../../../utils/textUtils';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import RiskDisclaimer from '../../../components/RiskDisclaimer';
import './WordCloud.css';
import {
BarChart, Bar,
PieChart, Pie, Cell,
XAxis, YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
Legend,
ResponsiveContainer,
Treemap,
Area, AreaChart,
} from 'recharts';
import ReactWordcloud from 'react-wordcloud';
// 颜色配置
const CHART_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
];
// 词云图组件
const WordCloud = ({ data }) => {
if (!data || data.length === 0) {
return (
<Center h="400px">
<VStack>
<Text color="gray.500">暂无词云数据</Text>
</VStack>
</Center>
);
}
const words = data.slice(0, 100).map(item => ({
text: item.name || item.text,
value: item.value || item.count || 1
}));
const options = {
rotations: 2,
rotationAngles: [-90, 0],
fontFamily: 'Microsoft YaHei, sans-serif',
fontSizes: [16, 80],
fontWeight: 'bold',
padding: 3,
scale: 'sqrt',
};
const callbacks = {
getWordColor: () => {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
return colors[Math.floor(Math.random() * colors.length)];
}
};
return (
<ReactWordcloud
words={words}
options={options}
callbacks={callbacks}
/>
);
};
// 板块热力图组件
const SectorHeatMap = ({ data }) => {
if (!data) return null;
const sectors = Object.entries(data)
.filter(([name]) => name !== '其他')
.map(([name, info]) => ({
name,
count: info.count,
stocks: info.stocks || []
}))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
const maxCount = Math.max(...sectors.map(s => s.count), 1);
const getColor = (count) => {
const intensity = count / maxCount;
if (intensity < 0.1) return '#E6F3FF';
if (intensity < 0.2) return '#CCE7FF';
if (intensity < 0.3) return '#99CFFF';
if (intensity < 0.4) return '#66B7FF';
if (intensity < 0.5) return '#339FFF';
if (intensity < 0.6) return '#0087FF';
if (intensity < 0.7) return '#0069CC';
if (intensity < 0.8) return '#004C99';
if (intensity < 0.9) return '#003066';
return '#001433';
};
const cols = 5;
const rows = Math.ceil(sectors.length / cols);
const cellWidth = 150;
const cellHeight = 100;
const padding = 10;
return (
<Box overflowX="auto" p={4}>
<VStack align="stretch" spacing={4}>
<Box>
<svg
width={cols * (cellWidth + padding) + padding}
height={rows * (cellHeight + padding) + padding}
>
{sectors.map((sector, index) => {
const row = Math.floor(index / cols);
const col = index % cols;
const x = col * (cellWidth + padding) + padding;
const y = row * (cellHeight + padding) + padding;
const color = getColor(sector.count);
return (
<g key={sector.name}>
<rect
x={x}
y={y}
width={cellWidth}
height={cellHeight}
fill={color}
stroke="#fff"
strokeWidth="2"
rx="8"
style={{
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.target.style.opacity = '0.8';
e.target.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.target.style.opacity = '1';
e.target.style.transform = 'scale(1)';
}}
>
<title>{`${sector.name}: ${sector.count}只涨停`}</title>
</rect>
<text
x={x + cellWidth / 2}
y={y + cellHeight / 2 - 10}
textAnchor="middle"
fill={sector.count / maxCount > 0.5 ? 'white' : '#2D3748'}
fontSize="14"
fontWeight="bold"
>
{sector.name.length > 8 ? sector.name.slice(0, 8) + '...' : sector.name}
</text>
<text
x={x + cellWidth / 2}
y={y + cellHeight / 2 + 10}
textAnchor="middle"
fill={sector.count / maxCount > 0.5 ? 'white' : '#2D3748'}
fontSize="24"
fontWeight="bold"
>
{sector.count}
</text>
<text
x={x + cellWidth / 2}
y={y + cellHeight / 2 + 28}
textAnchor="middle"
fill={sector.count / maxCount > 0.5 ? 'rgba(255,255,255,0.8)' : '#718096'}
fontSize="12"
>
只涨停
</text>
</g>
);
})}
</svg>
</Box>
<HStack justify="center" spacing={1}>
<Text fontSize="sm" color="gray.600" mr={2}></Text>
{[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1].map((intensity) => (
<Box
key={intensity}
w={6}
h={6}
bg={getColor(intensity * maxCount)}
borderRadius="sm"
border="1px solid"
borderColor="gray.200"
/>
))}
<Text fontSize="sm" color="gray.600" ml={2}></Text>
</HStack>
</VStack>
</Box>
);
};
// 板块关联图组件
const SectorRelationMap = ({ data }) => {
if (!data || !data.length) return null;
const sectors = [...new Set(data.flatMap(item => item.core_sectors || []))];
const sectorPairs = {};
data.forEach(stock => {
const stockSectors = stock.core_sectors || [];
for (let i = 0; i < stockSectors.length; i++) {
for (let j = i + 1; j < stockSectors.length; j++) {
const key = [stockSectors[i], stockSectors[j]].sort().join('-');
sectorPairs[key] = (sectorPairs[key] || 0) + 1;
}
}
});
const topSectors = sectors
.map(s => ({
name: s,
count: data.filter(d => (d.core_sectors || []).includes(s)).length
}))
.sort((a, b) => b.count - a.count)
.slice(0, 15)
.map(s => s.name);
const matrix = [];
const maxValue = Math.max(...Object.values(sectorPairs), 1);
topSectors.forEach((sector1, i) => {
topSectors.forEach((sector2, j) => {
if (i <= j) {
const key = [sector1, sector2].sort().join('-');
const value = i === j ?
data.filter(d => (d.core_sectors || []).includes(sector1)).length :
sectorPairs[key] || 0;
matrix.push({
x: j,
y: i,
value,
sector1,
sector2,
intensity: value / maxValue
});
}
});
});
const getColor = (intensity) => {
if (intensity === 0) return '#f7fafc';
if (intensity < 0.2) return '#bee3f8';
if (intensity < 0.4) return '#90cdf4';
if (intensity < 0.6) return '#63b3ed';
if (intensity < 0.8) return '#4299e1';
return '#3182ce';
};
const cellSize = 40;
const fontSize = 10;
return (
<Box overflowX="auto">
<svg width={topSectors.length * cellSize + 150} height={topSectors.length * cellSize + 100}>
{topSectors.map((sector, i) => (
<text
key={`y-${i}`}
x={140}
y={i * cellSize + cellSize / 2 + 55}
textAnchor="end"
fontSize={fontSize}
fill="#4a5568"
>
{sector}
</text>
))}
{topSectors.map((sector, i) => (
<text
key={`x-${i}`}
x={150 + i * cellSize + cellSize / 2}
y={40}
textAnchor="middle"
fontSize={fontSize}
fill="#4a5568"
transform={`rotate(-45, ${150 + i * cellSize + cellSize / 2}, 40)`}
>
{sector}
</text>
))}
{matrix.map((cell, i) => (
<g key={i}>
<rect
x={150 + cell.x * cellSize}
y={50 + cell.y * cellSize}
width={cellSize - 2}
height={cellSize - 2}
fill={getColor(cell.intensity)}
stroke="#e2e8f0"
strokeWidth="1"
rx="2"
>
<title>{`${cell.sector1} - ${cell.sector2}: ${cell.value}`}</title>
</rect>
{cell.value > 0 && (
<text
x={150 + cell.x * cellSize + cellSize / 2}
y={50 + cell.y * cellSize + cellSize / 2 + 4}
textAnchor="middle"
fontSize="10"
fill={cell.intensity > 0.5 ? "white" : "#2d3748"}
fontWeight="bold"
>
{cell.value}
</text>
)}
</g>
))}
<g transform={`translate(${topSectors.length * cellSize + 170}, 100)`}>
<text x="0" y="-10" fontSize="12" fontWeight="bold" fill="#2d3748">强度</text>
{[0, 0.2, 0.4, 0.6, 0.8, 1].map((intensity, i) => (
<g key={i} transform={`translate(0, ${i * 20})`}>
<rect
x="0"
y="0"
width="15"
height="15"
fill={getColor(intensity)}
stroke="#e2e8f0"
/>
<text x="20" y="12" fontSize="10" fill="#4a5568">
{intensity === 0 ? '0' : `${(intensity * maxValue).toFixed(0)}`}
</text>
</g>
))}
</g>
</svg>
</Box>
);
};
// 数据分析主组件
export const DataAnalysis = ({ dailyData, wordCloudData }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const pieData = useMemo(() => {
if (!dailyData?.chart_data) return [];
return dailyData.chart_data.labels.slice(0, 10).map((label, index) => ({
name: label,
value: dailyData.chart_data.counts[index],
}));
}, [dailyData]);
const timeDistributionData = useMemo(() => {
if (!dailyData?.summary?.zt_time_distribution) return [];
const dist = dailyData.summary.zt_time_distribution;
return [
{ name: '早盘(9:30-11:30)', value: dist.morning || 0, color: '#48BB78' },
{ name: '午盘(11:30-13:00)', value: dist.midday || 0, color: '#ED8936' },
{ name: '尾盘(13:00-15:00)', value: dist.afternoon || 0, color: '#4299E1' }
];
}, [dailyData]);
const allStocks = useMemo(() => {
if (!dailyData?.sector_data) return [];
return Object.values(dailyData.sector_data).flatMap(sector => sector.stocks || []);
}, [dailyData]);
return (
<Card bg={cardBg} borderRadius="xl" boxShadow="xl">
<CardHeader>
<Heading size="md">数据分析</Heading>
</CardHeader>
<CardBody>
<Tabs variant="soft-rounded" colorScheme="blue" isLazy>
<TabList mb={4} overflowX="auto">
<Tab>词云图</Tab>
<Tab>板块分布</Tab>
<Tab>板块热力图</Tab>
<Tab>板块关联</Tab>
<Tab>时间分布</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Box h="400px">
{wordCloudData && wordCloudData.length > 0 ? (
<WordCloud data={wordCloudData} />
) : dailyData?.word_freq_data && dailyData.word_freq_data.length > 0 ? (
<WordCloud data={dailyData.word_freq_data} />
) : (
<Center h="full">
<VStack>
<Text color="gray.500">暂无词云数据</Text>
</VStack>
</Center>
)}
</Box>
</TabPanel>
<TabPanel>
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={120}
fill="#8884d8"
dataKey="value"
animationBegin={0}
animationDuration={800}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<RechartsTooltip />
</PieChart>
</ResponsiveContainer>
</TabPanel>
<TabPanel>
<Box h="500px" overflowY="auto">
{dailyData?.sector_data ? (
<SectorHeatMap data={dailyData.sector_data} />
) : (
<Center h="400px">
<Text color="gray.500">暂无数据</Text>
</Center>
)}
</Box>
</TabPanel>
<TabPanel>
<Box h="500px" overflowY="auto">
{allStocks.length > 0 ? (
<SectorRelationMap data={allStocks} />
) : (
<Center h="400px">
<Text color="gray.500">暂无数据</Text>
</Center>
)}
</Box>
</TabPanel>
<TabPanel>
<VStack spacing={4}>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={timeDistributionData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{timeDistributionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<RechartsTooltip />
</PieChart>
</ResponsiveContainer>
<SimpleGrid columns={3} spacing={4} w="full">
{timeDistributionData.map((item, index) => (
<Stat key={index} textAlign="center">
<StatLabel fontSize="sm">{item.name}</StatLabel>
<StatNumber color={item.color}>{item.value}</StatNumber>
<StatHelpText>
{((item.value / (dailyData?.total_stocks || 1)) * 100).toFixed(1)}%
</StatHelpText>
</Stat>
))}
</SimpleGrid>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
// 股票详情弹窗组件
export const StockDetailModal = ({ isOpen, onClose, selectedStock }) => {
const accentColor = useColorModeValue('blue.500', 'blue.300');
if (!selectedStock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay backdropFilter="blur(10px)" />
<ModalContent>
<ModalHeader bg={accentColor} color="white">
<HStack>
<Avatar name={selectedStock.sname} bg="white" color={accentColor} />
<VStack align="start" spacing={0}>
<Heading size="md">{selectedStock.sname}</Heading>
<Text fontSize="sm">{selectedStock.scode}</Text>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
<VStack align="stretch" spacing={4}>
<Box>
<Text fontWeight="bold" mb={2}>涨停信息</Text>
<HStack spacing={3}>
<Badge colorScheme="blue" fontSize="md" p={2}>
涨停时间: {selectedStock.formatted_time || selectedStock.zt_time}
</Badge>
{selectedStock.continuous_days && (
<Badge colorScheme="red" fontSize="md" p={2}>
{selectedStock.continuous_days}
</Badge>
)}
</HStack>
</Box>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>涨停原因</Text>
<Box p={4} bg="gray.50" borderRadius="md">
<Text>{selectedStock.brief || '暂无涨停原因'}</Text>
</Box>
</Box>
{selectedStock.summary && (
<>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>详细分析</Text>
<Box p={4} bg="gray.50" borderRadius="md">
<Text {...getFormattedTextProps(selectedStock.summary).props}>
{getFormattedTextProps(selectedStock.summary).children}
</Text>
</Box>
</Box>
</>
)}
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>所属板块</Text>
<Wrap>
{selectedStock.core_sectors?.map((sector, i) => (
<WrapItem key={i}>
<Tag size="md" colorScheme="teal">
<TagLabel>{sector}</TagLabel>
</Tag>
</WrapItem>
))}
</Wrap>
</Box>
{/* 风险提示 */}
<RiskDisclaimer variant="default" />
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
关闭
</Button>
<Button colorScheme="blue" leftIcon={<ExternalLinkIcon />}>
查看K线图
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default { DataAnalysis, StockDetailModal };