620 lines
24 KiB
JavaScript
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 }; |