Initial commit
This commit is contained in:
616
src/views/LimitAnalyse/components/DataVisualizationComponents.js
Normal file
616
src/views/LimitAnalyse/components/DataVisualizationComponents.js
Normal file
@@ -0,0 +1,616 @@
|
||||
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 './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>
|
||||
</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 };
|
||||
Reference in New Issue
Block a user