refactor(MarketPanorama): 组件 TypeScript 转换与性能优化
- 将 MarketPanorama 及其子组件从 JS 转换为 TypeScript - 新增 types.ts 统一管理共享类型定义(SectorInfo、Stock 等) - 所有组件添加 memo() 包装优化渲染性能 - 使用 useCallback/useMemo 优化事件处理和计算 - 提取 TabButton、SortIcon 等子组件 - 常量配置集中管理(goldColors、TIME_PERIODS 等) 转换文件: - index.tsx (主组件) - SectorTreemap.tsx (板块热力图) - SectorNetwork.tsx (板块关联图) - MacroTabPanel.tsx (词云/饼图切换) - TimeDistributionChart.tsx (时间分布图) - SectorMovementTable.tsx (板块异动表格)
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* 宏观层 Tab 切换面板
|
||||
* 包含词云图和板块分布饼图的切换展示
|
||||
*/
|
||||
import React, { memo, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import 'echarts-wordcloud';
|
||||
import { Cloud, PieChart, Maximize2 } from 'lucide-react';
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface WordCloudItem {
|
||||
name?: string;
|
||||
text?: string;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
labels?: string[];
|
||||
counts?: number[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface MacroTabPanelProps {
|
||||
wordCloudData?: WordCloudItem[];
|
||||
chartData?: ChartData;
|
||||
compact?: boolean;
|
||||
showPieOnly?: boolean;
|
||||
selectedSector?: string | null;
|
||||
onSectorClick?: (sectorName: string) => void;
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
id: string;
|
||||
icon: React.ComponentType;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 黑金主题色系
|
||||
const goldColors = {
|
||||
primary: '#D4AF37',
|
||||
light: '#F4D03F',
|
||||
dark: '#B8860B',
|
||||
glow: 'rgba(212, 175, 55, 0.6)',
|
||||
};
|
||||
|
||||
// 分类颜色
|
||||
const categoryColors = [
|
||||
'#8b5cf6',
|
||||
'#3b82f6',
|
||||
'#06b6d4',
|
||||
'#22c55e',
|
||||
'#eab308',
|
||||
'#f97316',
|
||||
'#ef4444',
|
||||
'#ec4899',
|
||||
'#a855f7',
|
||||
'#14b8a6',
|
||||
];
|
||||
|
||||
// ============ 子组件 ============
|
||||
|
||||
/**
|
||||
* Tab 按钮组件
|
||||
*/
|
||||
const TabButton: React.FC<TabButtonProps> = memo(({ id, icon: IconComponent, label, isActive, onClick }) => (
|
||||
<HStack
|
||||
as="button"
|
||||
spacing={1.5}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="8px"
|
||||
bg={isActive ? 'rgba(212, 175, 55, 0.15)' : 'transparent'}
|
||||
border="1px solid"
|
||||
borderColor={isActive ? 'rgba(212, 175, 55, 0.4)' : 'rgba(255, 255, 255, 0.1)'}
|
||||
color={isActive ? goldColors.primary : 'rgba(255, 255, 255, 0.6)'}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
color: goldColors.light,
|
||||
}}
|
||||
onClick={() => onClick(id)}
|
||||
>
|
||||
<Icon as={IconComponent} boxSize={3.5} />
|
||||
<Text fontSize="xs" fontWeight="500">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
));
|
||||
|
||||
TabButton.displayName = 'TabButton';
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
const MacroTabPanel: React.FC<MacroTabPanelProps> = memo(
|
||||
({ wordCloudData = [], chartData = {}, compact = false, showPieOnly = false, selectedSector, onSectorClick }) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('wordcloud');
|
||||
|
||||
// 全屏 Modal hook
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// 词云配置
|
||||
const wordCloudOption = useMemo(() => {
|
||||
if (!wordCloudData || wordCloudData.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...wordCloudData.map((d) => d.value || 10));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
show: true,
|
||||
backgroundColor: 'rgba(15, 15, 22, 0.95)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: [8, 12],
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: { name: string; value: number }) => {
|
||||
return `<span style="color: ${goldColors.primary}; font-weight: 600;">${params.name}</span><br/>
|
||||
热度: <span style="color: #ef4444; font-weight: 600;">${params.value}</span>`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'wordCloud',
|
||||
shape: 'circle',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
width: '90%',
|
||||
height: '85%',
|
||||
sizeRange: [14, 48],
|
||||
rotationRange: [-30, 30],
|
||||
rotationStep: 15,
|
||||
gridSize: 10,
|
||||
drawOutOfBound: false,
|
||||
layoutAnimation: true,
|
||||
textStyle: {
|
||||
fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif',
|
||||
fontWeight: 600,
|
||||
color: (params: { value: number }) => {
|
||||
const ratio = params.value / maxValue;
|
||||
if (ratio > 0.7) return '#ef4444';
|
||||
if (ratio > 0.5) return '#f97316';
|
||||
if (ratio > 0.3) return goldColors.primary;
|
||||
return '#60a5fa';
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
textStyle: {
|
||||
textShadow: `0 0 15px ${goldColors.primary}`,
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
data: wordCloudData.map((item) => ({
|
||||
name: item.name || item.text,
|
||||
value: item.value || 10,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [wordCloudData]);
|
||||
|
||||
// 饼图配置
|
||||
const pieOption = useMemo(() => {
|
||||
if (!chartData.labels || chartData.labels.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const hasSelection = !!selectedSector;
|
||||
|
||||
const pieData = chartData.labels.map((label, index) => {
|
||||
const isSelected = selectedSector === label;
|
||||
const baseColor = categoryColors[index % categoryColors.length];
|
||||
|
||||
return {
|
||||
name: label,
|
||||
value: chartData.counts?.[index] || 0,
|
||||
selected: isSelected,
|
||||
itemStyle: {
|
||||
color: baseColor,
|
||||
opacity: !hasSelection || isSelected ? 1 : 0.3,
|
||||
borderColor: isSelected ? goldColors.primary : 'rgba(0, 0, 0, 0.3)',
|
||||
borderWidth: isSelected ? 3 : 2,
|
||||
shadowBlur: isSelected ? 15 : 0,
|
||||
shadowColor: isSelected ? goldColors.glow : 'transparent',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(15, 15, 22, 0.95)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: [10, 14],
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: { name: string; value: number; percent: number }) => {
|
||||
return `<span style="color: ${goldColors.primary}; font-weight: 600;">${params.name}</span><br/>
|
||||
涨停数: <span style="color: #ef4444; font-weight: 600;">${params.value} 只</span><br/>
|
||||
占比: <span style="font-weight: 600;">${params.percent.toFixed(1)}%</span>`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['35%', '70%'],
|
||||
center: ['50%', '55%'],
|
||||
avoidLabelOverlap: true,
|
||||
selectedMode: 'single',
|
||||
selectedOffset: 12,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}\n{c}只',
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
lineHeight: 16,
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 10,
|
||||
length2: 15,
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
scale: true,
|
||||
scaleSize: 8,
|
||||
itemStyle: {
|
||||
shadowBlur: 20,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
data: pieData,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [chartData, selectedSector]);
|
||||
|
||||
// 紧凑模式饼图配置 - 适合侧边栏
|
||||
const compactPieOption = useMemo(() => {
|
||||
if (!chartData.labels || chartData.labels.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const pieData = chartData.labels.map((label, index) => ({
|
||||
name: label,
|
||||
value: chartData.counts?.[index] || 0,
|
||||
itemStyle: {
|
||||
color: categoryColors[index % categoryColors.length],
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(15, 15, 22, 0.95)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: [8, 12],
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 11,
|
||||
},
|
||||
formatter: (params: { name: string; value: number; percent: number }) => {
|
||||
return `<span style="color: ${goldColors.primary}; font-weight: 600;">${params.name}</span><br/>
|
||||
涨停数: <span style="color: #ef4444; font-weight: 600;">${params.value} 只</span><br/>
|
||||
占比: <span style="font-weight: 600;">${params.percent.toFixed(1)}%</span>`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['30%', '65%'],
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
borderColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: (params: { name: string; percent: number }) => {
|
||||
// 只显示占比大于 5% 的标签
|
||||
if (params.percent < 5) return '';
|
||||
return `${params.name} ${params.percent.toFixed(1)}%`;
|
||||
},
|
||||
fontSize: 10,
|
||||
color: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 8,
|
||||
length2: 10,
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
scale: true,
|
||||
scaleSize: 5,
|
||||
itemStyle: {
|
||||
shadowBlur: 15,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
},
|
||||
data: pieData,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [chartData]);
|
||||
|
||||
const hasWordCloud = wordCloudData && wordCloudData.length > 0;
|
||||
const hasPieData = chartData.labels && chartData.labels.length > 0;
|
||||
|
||||
// 饼图点击事件
|
||||
const pieEvents = useMemo(
|
||||
() => ({
|
||||
click: (params: { data?: { name: string }; name?: string }) => {
|
||||
if (params.data && onSectorClick) {
|
||||
onSectorClick(params.name || params.data.name);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[onSectorClick]
|
||||
);
|
||||
|
||||
// Tab 切换处理
|
||||
const handleTabClick = useCallback((id: string) => {
|
||||
setActiveTab(id);
|
||||
}, []);
|
||||
|
||||
// Compact 模式:只显示词云
|
||||
if (compact) {
|
||||
return (
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* 标题行 */}
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={Cloud} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="sm" fontWeight="600" color="rgba(255, 255, 255, 0.9)">
|
||||
热门概念词云
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 词云 */}
|
||||
<Box flex={1} minH={0}>
|
||||
{hasWordCloud ? (
|
||||
<ReactECharts option={wordCloudOption} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
||||
) : (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={Cloud} boxSize={6} />
|
||||
<Text fontSize="xs">暂无词云数据</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// showPieOnly 模式:只显示板块分布饼图
|
||||
if (showPieOnly) {
|
||||
return (
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={PieChart} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="sm" fontWeight="600" color="rgba(255, 255, 255, 0.9)">
|
||||
板块分布
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 饼图 */}
|
||||
<Box flex={1} minH={0}>
|
||||
{hasPieData ? (
|
||||
<ReactECharts
|
||||
option={pieOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
onEvents={pieEvents}
|
||||
/>
|
||||
) : (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={PieChart} boxSize={6} />
|
||||
<Text fontSize="xs">暂无分布数据</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认模式:Tab 切换
|
||||
return (
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* Tab 切换 */}
|
||||
<HStack spacing={2} mb={3}>
|
||||
<TabButton id="wordcloud" icon={Cloud} label="热点词云" isActive={activeTab === 'wordcloud'} onClick={handleTabClick} />
|
||||
<TabButton id="pie" icon={PieChart} label="板块分布" isActive={activeTab === 'pie'} onClick={handleTabClick} />
|
||||
</HStack>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Box flex={1} minH={0}>
|
||||
{activeTab === 'wordcloud' ? (
|
||||
hasWordCloud ? (
|
||||
<ReactECharts option={wordCloudOption} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
||||
) : (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={Cloud} boxSize={8} />
|
||||
<Text fontSize="sm">暂无词云数据</Text>
|
||||
</VStack>
|
||||
)
|
||||
) : hasPieData ? (
|
||||
<ReactECharts option={pieOption} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
||||
) : (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={PieChart} boxSize={8} />
|
||||
<Text fontSize="sm">暂无分布数据</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MacroTabPanel.displayName = 'MacroTabPanel';
|
||||
|
||||
export default MacroTabPanel;
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 板块异动明细表格
|
||||
* 展示板块涨停数量、净流入、领涨股等信息
|
||||
*/
|
||||
import React, { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { Box, Text, HStack, VStack, Icon, Table, Thead, Tbody, Tr, Th, Td, Badge, Tooltip } from '@chakra-ui/react';
|
||||
import { Activity, ArrowUp, ArrowDown, Zap, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface Stock {
|
||||
scode?: string;
|
||||
sname?: string;
|
||||
name?: string;
|
||||
change_pct?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SectorInfo {
|
||||
count?: number;
|
||||
stocks?: Stock[];
|
||||
net_inflow?: number;
|
||||
leading_stock?: string;
|
||||
}
|
||||
|
||||
interface SectorMovementTableProps {
|
||||
sectorData?: Record<string, SectorInfo>;
|
||||
selectedSector?: string | null;
|
||||
onSectorClick?: (sectorName: string) => void;
|
||||
}
|
||||
|
||||
interface TableRow {
|
||||
name: string;
|
||||
count: number;
|
||||
netInflow: number;
|
||||
leadingStock: string;
|
||||
leadingStockCode: string;
|
||||
leadingStockChange: string | number;
|
||||
stocks: Stock[];
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'count' | 'netInflow';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 黑金主题色系
|
||||
const goldColors = {
|
||||
primary: '#D4AF37',
|
||||
light: '#F4D03F',
|
||||
dark: '#B8860B',
|
||||
};
|
||||
|
||||
// 表头样式
|
||||
const thStyle = {
|
||||
px: 3,
|
||||
py: 2,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: 'xs',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
bg: 'rgba(255, 255, 255, 0.02)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'rgba(255, 255, 255, 0.04)',
|
||||
color: goldColors.primary,
|
||||
},
|
||||
};
|
||||
|
||||
// 单元格样式
|
||||
const tdStyle = {
|
||||
px: 3,
|
||||
py: 2.5,
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
|
||||
fontSize: 'sm',
|
||||
};
|
||||
|
||||
// ============ 子组件 ============
|
||||
|
||||
/**
|
||||
* 排序图标组件
|
||||
*/
|
||||
const SortIcon: React.FC<{ field: SortField; currentField: SortField; order: SortOrder }> = memo(({ field, currentField, order }) => {
|
||||
if (currentField !== field) return null;
|
||||
return order === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />;
|
||||
});
|
||||
|
||||
SortIcon.displayName = 'SortIcon';
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
const SectorMovementTable: React.FC<SectorMovementTableProps> = memo(({ sectorData = {}, selectedSector, onSectorClick }) => {
|
||||
const [sortField, setSortField] = useState<SortField>('count');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
|
||||
// 处理表格数据
|
||||
const tableData = useMemo<TableRow[]>(() => {
|
||||
const sectors = Object.entries(sectorData)
|
||||
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||
.map(([name, info]): TableRow => {
|
||||
const stocks = info.stocks || [];
|
||||
const leadingStock = stocks.length > 0 ? stocks[0] : null;
|
||||
|
||||
// 模拟净流入数据(实际应从 API 获取)
|
||||
const netInflow = info.net_inflow ?? parseFloat((Math.random() * 30 - 10).toFixed(2));
|
||||
|
||||
return {
|
||||
name,
|
||||
count: info.count || stocks.length || 0,
|
||||
netInflow: typeof netInflow === 'number' ? netInflow : parseFloat(netInflow),
|
||||
leadingStock: leadingStock?.sname || info.leading_stock || '-',
|
||||
leadingStockCode: leadingStock?.scode || '',
|
||||
leadingStockChange: leadingStock?.change_pct || (Math.random() * 3 + 8).toFixed(2),
|
||||
stocks,
|
||||
};
|
||||
});
|
||||
|
||||
// 排序
|
||||
sectors.sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
const order = sortOrder === 'asc' ? 1 : -1;
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return (aVal - bVal) * order;
|
||||
}
|
||||
return String(aVal).localeCompare(String(bVal)) * order;
|
||||
});
|
||||
|
||||
return sectors;
|
||||
}, [sectorData, sortField, sortOrder]);
|
||||
|
||||
// 切换排序
|
||||
const handleSort = useCallback(
|
||||
(field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
},
|
||||
[sortField, sortOrder]
|
||||
);
|
||||
|
||||
// 行点击处理
|
||||
const handleRowClick = useCallback(
|
||||
(sectorName: string) => {
|
||||
onSectorClick?.(sectorName);
|
||||
setExpandedRow((prev) => (prev === sectorName ? null : sectorName));
|
||||
},
|
||||
[onSectorClick]
|
||||
);
|
||||
|
||||
if (tableData.length === 0) {
|
||||
return (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)" py={8}>
|
||||
<Icon as={Activity} boxSize={10} />
|
||||
<Text fontSize="sm">暂无板块数据</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2} mb={3}>
|
||||
<Icon as={Activity} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="sm" fontWeight="600" color="rgba(255, 255, 255, 0.9)" letterSpacing="0.5px">
|
||||
板块异动明细
|
||||
</Text>
|
||||
<Badge bg="rgba(212, 175, 55, 0.15)" color={goldColors.primary} fontSize="xs" px={2} borderRadius="full">
|
||||
{tableData.length} 个板块
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 表格 */}
|
||||
<Box borderRadius="12px" border="1px solid rgba(255, 255, 255, 0.06)" overflow="hidden" bg="rgba(0, 0, 0, 0.2)">
|
||||
<Box overflowX="auto" maxH="320px" overflowY="auto">
|
||||
<Table variant="unstyled" size="sm">
|
||||
<Thead position="sticky" top={0} zIndex={1}>
|
||||
<Tr>
|
||||
<Th {...thStyle} onClick={() => handleSort('name')}>
|
||||
<HStack spacing={1}>
|
||||
<Text>板块名称</Text>
|
||||
<SortIcon field="name" currentField={sortField} order={sortOrder} />
|
||||
</HStack>
|
||||
</Th>
|
||||
<Th {...thStyle} onClick={() => handleSort('count')} isNumeric>
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
<Text>涨停数</Text>
|
||||
<SortIcon field="count" currentField={sortField} order={sortOrder} />
|
||||
</HStack>
|
||||
</Th>
|
||||
<Th {...thStyle} onClick={() => handleSort('netInflow')} isNumeric>
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
<Text>净流入(亿)</Text>
|
||||
<SortIcon field="netInflow" currentField={sortField} order={sortOrder} />
|
||||
</HStack>
|
||||
</Th>
|
||||
<Th {...thStyle}>
|
||||
<Text>领涨股</Text>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tableData.map((sector, index) => {
|
||||
const isSelected = selectedSector === sector.name;
|
||||
const isExpanded = expandedRow === sector.name;
|
||||
|
||||
return (
|
||||
<React.Fragment key={sector.name}>
|
||||
<Tr
|
||||
onClick={() => handleRowClick(sector.name)}
|
||||
cursor="pointer"
|
||||
bg={isSelected ? 'rgba(212, 175, 55, 0.1)' : 'transparent'}
|
||||
_hover={{
|
||||
bg: isSelected ? 'rgba(212, 175, 55, 0.15)' : 'rgba(255, 255, 255, 0.03)',
|
||||
}}
|
||||
transition="background 0.15s"
|
||||
>
|
||||
<Td {...tdStyle}>
|
||||
<HStack spacing={2}>
|
||||
{index < 3 && (
|
||||
<Badge
|
||||
bg={
|
||||
index === 0 ? 'rgba(239, 68, 68, 0.2)' : index === 1 ? 'rgba(249, 115, 22, 0.2)' : 'rgba(234, 179, 8, 0.2)'
|
||||
}
|
||||
color={index === 0 ? '#ef4444' : index === 1 ? '#f97316' : '#eab308'}
|
||||
fontSize="2xs"
|
||||
px={1.5}
|
||||
borderRadius="sm"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
)}
|
||||
<Text fontWeight="500" color={isSelected ? goldColors.primary : 'rgba(255, 255, 255, 0.9)'}>
|
||||
{sector.name}
|
||||
</Text>
|
||||
<Icon as={isExpanded ? ChevronUp : ChevronDown} boxSize={3} color="rgba(255, 255, 255, 0.4)" />
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td {...tdStyle} isNumeric>
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
<Icon as={Zap} boxSize={3} color="#ef4444" />
|
||||
<Text fontWeight="600" color="#ef4444">
|
||||
{sector.count}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td {...tdStyle} isNumeric>
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
<Icon as={sector.netInflow >= 0 ? ArrowUp : ArrowDown} boxSize={3} color={sector.netInflow >= 0 ? '#ef4444' : '#22c55e'} />
|
||||
<Text fontWeight="600" color={sector.netInflow >= 0 ? '#ef4444' : '#22c55e'}>
|
||||
{sector.netInflow >= 0 ? '+' : ''}
|
||||
{sector.netInflow.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td {...tdStyle}>
|
||||
<Tooltip
|
||||
label={`${sector.leadingStockCode} +${sector.leadingStockChange}%`}
|
||||
placement="top"
|
||||
bg="rgba(15, 15, 22, 0.95)"
|
||||
color="rgba(255, 255, 255, 0.9)"
|
||||
borderRadius="8px"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Text color="#60a5fa" fontWeight="500">
|
||||
{sector.leadingStock}
|
||||
</Text>
|
||||
<Badge bg="rgba(239, 68, 68, 0.15)" color="#ef4444" fontSize="2xs" px={1.5}>
|
||||
+{sector.leadingStockChange}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{/* 展开的股票列表 */}
|
||||
{isExpanded && sector.stocks.length > 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4} p={0}>
|
||||
<Box bg="rgba(0, 0, 0, 0.3)" borderTop="1px solid rgba(255, 255, 255, 0.05)" px={4} py={3}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)" mb={2}>
|
||||
成分股 ({sector.stocks.length}只)
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{sector.stocks.slice(0, 8).map((stock, idx) => (
|
||||
<Badge
|
||||
key={stock.scode || idx}
|
||||
bg="rgba(96, 165, 250, 0.1)"
|
||||
color="#60a5fa"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
bg: 'rgba(96, 165, 250, 0.2)',
|
||||
}}
|
||||
>
|
||||
{stock.sname || stock.name}
|
||||
</Badge>
|
||||
))}
|
||||
{sector.stocks.length > 8 && (
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.4)">
|
||||
+{sector.stocks.length - 8} 只
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SectorMovementTable.displayName = 'SectorMovementTable';
|
||||
|
||||
export default SectorMovementTable;
|
||||
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* 板块关联图 - 环形分层布局
|
||||
* 展示板块之间的联动关系
|
||||
*
|
||||
* 优化:
|
||||
* 1. 环形布局 - 节点围绕中心排列
|
||||
* 2. 层级布局 - 大节点在内圈,小节点在外圈
|
||||
* 3. 分类聚合 - 同类板块相邻
|
||||
* 4. 减少连线 - 只保留强关联
|
||||
*/
|
||||
import React, { memo, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Link,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { Network, Maximize2 } from 'lucide-react';
|
||||
|
||||
// 导入共享类型
|
||||
import type { SectorInfo, Stock, SectorRelation } from '../types';
|
||||
|
||||
interface SectorNetworkProps {
|
||||
sectorData?: Record<string, SectorInfo>;
|
||||
sectorRelations?: SectorRelation[];
|
||||
selectedSector?: string | null;
|
||||
onSectorClick?: (sectorName: string) => void;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
x: number;
|
||||
y: number;
|
||||
fixed: boolean;
|
||||
symbolSize: number;
|
||||
category: string;
|
||||
itemStyle: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkStock {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
stocks?: LinkStock[];
|
||||
lineStyle: {
|
||||
width: number;
|
||||
opacity: number;
|
||||
type?: string;
|
||||
color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 黑金主题色系
|
||||
const goldColors = {
|
||||
primary: '#D4AF37',
|
||||
light: '#F4D03F',
|
||||
dark: '#B8860B',
|
||||
glow: 'rgba(212, 175, 55, 0.6)',
|
||||
};
|
||||
|
||||
// 板块分类颜色 - 优化色彩
|
||||
const categoryColors: Record<string, string> = {
|
||||
科技: '#3b82f6', // 蓝色
|
||||
新能源: '#22c55e', // 绿色
|
||||
医药: '#06b6d4', // 青色
|
||||
金融: '#eab308', // 金色
|
||||
消费: '#f97316', // 橙色
|
||||
周期: '#ef4444', // 红色
|
||||
其他: '#8b5cf6', // 紫色
|
||||
};
|
||||
|
||||
// 分类顺序(用于聚合排列)
|
||||
const categoryOrder = ['科技', '新能源', '医药', '金融', '消费', '周期', '其他'];
|
||||
|
||||
// 分类关键词映射
|
||||
const categoryKeywords: Record<string, string[]> = {
|
||||
科技: ['人工智能', 'ChatGPT', '大模型', '算力', '芯片', '半导体', '5G通信', '数字经济', '机器人', '无人驾驶', '云计算', '大数据'],
|
||||
新能源: ['新能源汽车', '光伏', '锂电池', '储能', '充电桩', '风电', '氢能源'],
|
||||
医药: ['医疗器械', '创新药', 'CXO', '医药', '生物医药', '中药'],
|
||||
金融: ['券商', '银行', '保险', '金融科技', '信托'],
|
||||
消费: ['白酒', '食品饮料', '消费电子', '零售', '家电', '旅游'],
|
||||
周期: ['钢铁', '煤炭', '有色金属', '化工', '建材', '航运'],
|
||||
};
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/**
|
||||
* 根据板块名称推断分类
|
||||
*/
|
||||
const inferCategory = (sectorName: string): string => {
|
||||
for (const [category, keywords] of Object.entries(categoryKeywords)) {
|
||||
if (keywords.includes(sectorName)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return '其他';
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算环形布局坐标(使用百分比,范围 0-100)
|
||||
*/
|
||||
const getCircularPosition = (
|
||||
index: number,
|
||||
total: number,
|
||||
radiusPercent: number,
|
||||
offsetAngle: number = 0
|
||||
): { x: number; y: number } => {
|
||||
const angle = (2 * Math.PI * index) / total + offsetAngle;
|
||||
return {
|
||||
x: 50 + radiusPercent * Math.cos(angle), // 中心点在 50%
|
||||
y: 50 + radiusPercent * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算与选中节点相连的节点集合
|
||||
*/
|
||||
const getRelatedNodes = (sectorName: string | null | undefined, graphLinks: GraphLink[]): Set<string> => {
|
||||
if (!sectorName) return new Set();
|
||||
const related = new Set<string>();
|
||||
graphLinks.forEach((link) => {
|
||||
if (link.source === sectorName) {
|
||||
related.add(link.target);
|
||||
} else if (link.target === sectorName) {
|
||||
related.add(link.source);
|
||||
}
|
||||
});
|
||||
return related;
|
||||
};
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
const SectorNetwork: React.FC<SectorNetworkProps> = memo(({ sectorData = {}, sectorRelations, selectedSector, onSectorClick }) => {
|
||||
// 连线点击状态
|
||||
const [selectedLink, setSelectedLink] = useState<GraphLink | null>(null);
|
||||
|
||||
// 全屏 Modal
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// 股票列表 Modal
|
||||
const { isOpen: isStocksModalOpen, onOpen: onStocksModalOpen, onClose: onStocksModalClose } = useDisclosure();
|
||||
|
||||
// 生成网络图数据 - 环形分层布局
|
||||
const { nodes, links } = useMemo(() => {
|
||||
const sectors = Object.entries(sectorData)
|
||||
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||
.map(([name, info]) => ({
|
||||
name,
|
||||
value: info.count || 0,
|
||||
stocks: info.stocks || [],
|
||||
category: inferCategory(name),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 15); // 最多显示 15 个节点
|
||||
|
||||
if (sectors.length === 0) {
|
||||
return { nodes: [] as GraphNode[], links: [] as GraphLink[] };
|
||||
}
|
||||
|
||||
// 按分类聚合排序
|
||||
const sortedSectors = [...sectors].sort((a, b) => {
|
||||
const catA = categoryOrder.indexOf(a.category);
|
||||
const catB = categoryOrder.indexOf(b.category);
|
||||
if (catA !== catB) return catA - catB;
|
||||
return b.value - a.value; // 同分类内按涨停数排序
|
||||
});
|
||||
|
||||
// 分层:内圈(涨停数前5)、外圈(其余)
|
||||
const maxValue = Math.max(...sectors.map((s) => s.value));
|
||||
const innerCount = Math.min(5, Math.ceil(sectors.length / 3));
|
||||
|
||||
const nodeMap = new Map<string, number>();
|
||||
const graphNodes: GraphNode[] = sortedSectors.map((sector, index) => {
|
||||
nodeMap.set(sector.name, index);
|
||||
|
||||
// 计算层级和位置(使用百分比半径,让图表自适应容器)
|
||||
const isInner = index < innerCount;
|
||||
const layerIndex = isInner ? index : index - innerCount;
|
||||
const layerTotal = isInner ? innerCount : sortedSectors.length - innerCount;
|
||||
const radiusPercent = isInner ? 15 : 32; // 内圈15%,外圈32%(留出边距给标签)
|
||||
|
||||
const pos = getCircularPosition(layerIndex, layerTotal, radiusPercent, -Math.PI / 2);
|
||||
|
||||
// 节点大小:根据涨停数计算,内圈更大
|
||||
const sizeRatio = sector.value / maxValue;
|
||||
const baseSize = isInner ? 45 : 30;
|
||||
const symbolSize = baseSize + sizeRatio * (isInner ? 25 : 15);
|
||||
|
||||
return {
|
||||
id: sector.name,
|
||||
name: sector.name,
|
||||
value: sector.value,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
fixed: true, // 固定位置,不使用力导向
|
||||
symbolSize: symbolSize,
|
||||
category: sector.category,
|
||||
itemStyle: {
|
||||
color: categoryColors[sector.category] || categoryColors['其他'],
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 计算关联 - 只保留强关联(同分类或股票重叠度高)
|
||||
const graphLinks: GraphLink[] = [];
|
||||
const linkSet = new Set<string>();
|
||||
const linkStrength = new Map<string, number>(); // 记录关联强度
|
||||
const linkStocks = new Map<string, LinkStock[]>(); // 记录两板块交集股票
|
||||
|
||||
// 基于股票重叠计算关联强度
|
||||
sortedSectors.forEach((sector) => {
|
||||
if (!sector.stocks) return;
|
||||
|
||||
sector.stocks.forEach((stock) => {
|
||||
const coreSectors = stock.core_sectors || [];
|
||||
coreSectors.forEach((otherSector) => {
|
||||
if (otherSector !== sector.name && nodeMap.has(otherSector)) {
|
||||
const key = [sector.name, otherSector].sort().join('-');
|
||||
linkStrength.set(key, (linkStrength.get(key) || 0) + 1);
|
||||
// 记录交集股票
|
||||
if (!linkStocks.has(key)) {
|
||||
linkStocks.set(key, []);
|
||||
}
|
||||
// 避免重复添加(兼容 scode/sname 和 stock_code/stock_name)
|
||||
const existingStocks = linkStocks.get(key)!;
|
||||
const stockCode = stock.scode || stock.stock_code || stock.code || '';
|
||||
const stockName = stock.sname || stock.stock_name || stock.name || '';
|
||||
if (stockCode && !existingStocks.find((s) => s.stock_code === stockCode)) {
|
||||
existingStocks.push({
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 只保留强关联(重叠度 >= 2 或同分类相邻)
|
||||
linkStrength.forEach((strength, key) => {
|
||||
if (strength >= 2 && !linkSet.has(key)) {
|
||||
const [source, target] = key.split('-');
|
||||
const stocks = linkStocks.get(key) || [];
|
||||
graphLinks.push({
|
||||
source,
|
||||
target,
|
||||
value: strength,
|
||||
stocks, // 存储关联股票列表
|
||||
lineStyle: {
|
||||
width: Math.min(3, 1 + strength * 0.5),
|
||||
opacity: Math.min(0.6, 0.2 + strength * 0.1),
|
||||
},
|
||||
});
|
||||
linkSet.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// 同分类相邻节点添加弱连接(最多3条)
|
||||
let categoryLinks = 0;
|
||||
for (let i = 0; i < sortedSectors.length - 1 && categoryLinks < 3; i++) {
|
||||
const curr = sortedSectors[i];
|
||||
const next = sortedSectors[i + 1];
|
||||
if (curr.category === next.category) {
|
||||
const key = [curr.name, next.name].sort().join('-');
|
||||
if (!linkSet.has(key)) {
|
||||
graphLinks.push({
|
||||
source: curr.name,
|
||||
target: next.name,
|
||||
value: 1,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
opacity: 0.15,
|
||||
type: 'dashed',
|
||||
},
|
||||
});
|
||||
linkSet.add(key);
|
||||
categoryLinks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: graphNodes, links: graphLinks };
|
||||
}, [sectorData, sectorRelations]);
|
||||
|
||||
// 生成 ECharts 配置 - 支持不同标签大小和节点缩放
|
||||
const createOption = useCallback(
|
||||
(labelSize: number = 11, nodeScale: number = 1): Record<string, unknown> => {
|
||||
if (nodes.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const hasSelection = !!selectedSector;
|
||||
const relatedNodes = getRelatedNodes(selectedSector, links);
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(15, 15, 22, 0.95)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: [10, 14],
|
||||
confine: false, // 不限制在容器内,防止被裁切
|
||||
appendToBody: true, // 将 tooltip 添加到 body,避免被父容器 overflow 裁切
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: { dataType: string; name: string; value: number; data: GraphNode | GraphLink }) => {
|
||||
if (params.dataType === 'node') {
|
||||
const nodeData = params.data as GraphNode;
|
||||
const category = nodeData.category || '其他';
|
||||
const color = categoryColors[category] || categoryColors['其他'];
|
||||
return `<div style="min-width: 120px;">
|
||||
<span style="color: ${color}; font-weight: 600;">${params.name}</span>
|
||||
<span style="color: rgba(255,255,255,0.5); font-size: 11px; margin-left: 6px;">${category}</span><br/>
|
||||
涨停数: <span style="color: #ef4444; font-weight: 600;">${params.value} 只</span>
|
||||
</div>`;
|
||||
}
|
||||
// 连线 tooltip - 显示板块名称和关联股票
|
||||
if (params.dataType === 'edge') {
|
||||
const linkData = params.data as GraphLink;
|
||||
const stocks = linkData.stocks || [];
|
||||
const stockCount = stocks.length;
|
||||
// 确保显示股票名称,兼容不同数据结构
|
||||
const displayStocks =
|
||||
stockCount > 0
|
||||
? stocks
|
||||
.slice(0, 5)
|
||||
.map((s) => s.stock_name || '未知')
|
||||
.join('、')
|
||||
: '无';
|
||||
const hasMore = stockCount > 5;
|
||||
|
||||
return `<div style="min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">
|
||||
${linkData.source} <span style="color: ${goldColors.light};">↔</span> ${linkData.target}
|
||||
</div>
|
||||
<div style="color: rgba(255,255,255,0.6); font-size: 11px; margin-bottom: 4px;">
|
||||
关联股票(${stockCount}只):
|
||||
</div>
|
||||
<div style="color: ${goldColors.light}; font-size: 12px; line-height: 1.5;">
|
||||
${displayStocks}${hasMore ? '...' : ''}
|
||||
</div>
|
||||
${
|
||||
stockCount > 0
|
||||
? `<div style="color: rgba(255,255,255,0.5); font-size: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||
👆 点击连线查看全部
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'none', // 使用固定坐标,不用力导向
|
||||
coordinateSystem: null, // 不使用坐标系,直接使用百分比定位
|
||||
roam: true,
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '8%',
|
||||
bottom: '12%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
distance: 8 * nodeScale,
|
||||
fontSize: labelSize,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
formatter: '{b}',
|
||||
textBorderColor: 'rgba(0, 0, 0, 0.6)',
|
||||
textBorderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: goldColors.primary,
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 25,
|
||||
shadowColor: goldColors.primary,
|
||||
},
|
||||
label: {
|
||||
color: goldColors.light,
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.12)',
|
||||
width: 1 * nodeScale,
|
||||
curveness: 0.3,
|
||||
},
|
||||
data: nodes.map((node) => {
|
||||
const isSelected = selectedSector === node.id;
|
||||
const isRelated = relatedNodes.has(node.id);
|
||||
|
||||
// 计算节点大小:选中节点放大 1.3 倍
|
||||
const sizeMultiplier = isSelected ? 1.3 : 1;
|
||||
const finalSize = node.symbolSize * nodeScale * sizeMultiplier;
|
||||
|
||||
// 计算透明度:无选择或选中/相关节点正常,其他节点降低透明度
|
||||
const opacity = !hasSelection || isSelected || isRelated ? 1 : 0.3;
|
||||
|
||||
return {
|
||||
...node,
|
||||
symbolSize: finalSize,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: node.name,
|
||||
color: isSelected ? goldColors.light : `rgba(255, 255, 255, ${opacity * 0.85})`,
|
||||
fontWeight: isSelected ? 700 : 500,
|
||||
},
|
||||
itemStyle: {
|
||||
...node.itemStyle,
|
||||
opacity: opacity,
|
||||
borderColor: isSelected ? goldColors.primary : 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: isSelected ? 3 : 2,
|
||||
shadowBlur: isSelected ? 20 : 0,
|
||||
shadowColor: isSelected ? goldColors.glow : 'transparent',
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: goldColors.primary,
|
||||
borderWidth: 3,
|
||||
shadowBlur: 20,
|
||||
shadowColor: node.itemStyle?.color || goldColors.primary,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
links: links.map((link) => {
|
||||
const isRelatedLink = link.source === selectedSector || link.target === selectedSector;
|
||||
|
||||
return {
|
||||
...link,
|
||||
lineStyle: {
|
||||
...link.lineStyle,
|
||||
width: (link.lineStyle?.width || 1) * nodeScale * (isRelatedLink ? 1.5 : 1),
|
||||
opacity: !hasSelection ? link.lineStyle?.opacity || 0.2 : isRelatedLink ? 0.6 : 0.08,
|
||||
color: isRelatedLink ? goldColors.primary : undefined,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
[nodes, links, selectedSector]
|
||||
);
|
||||
|
||||
// 普通模式配置
|
||||
const option = useMemo(() => createOption(10, 1), [createOption]);
|
||||
|
||||
// 全屏模式配置 - 更大的字体和节点
|
||||
const fullscreenOption = useMemo(() => createOption(14, 1.1), [createOption]);
|
||||
|
||||
// 连线点击处理
|
||||
const handleLinkClick = useCallback(
|
||||
(linkData: GraphLink) => {
|
||||
if (linkData.stocks && linkData.stocks.length > 0) {
|
||||
setSelectedLink(linkData);
|
||||
onStocksModalOpen();
|
||||
}
|
||||
},
|
||||
[onStocksModalOpen]
|
||||
);
|
||||
|
||||
// 点击事件
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
click: (params: { dataType: string; name?: string; data?: GraphLink }) => {
|
||||
if (params.dataType === 'node' && onSectorClick && params.name) {
|
||||
onSectorClick(params.name);
|
||||
} else if (params.dataType === 'edge' && params.data) {
|
||||
handleLinkClick(params.data);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[onSectorClick, handleLinkClick]
|
||||
);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={Network} boxSize={8} />
|
||||
<Text fontSize="sm">暂无关联数据</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
// 网络图组件(复用于普通和全屏模式)
|
||||
const NetworkChart = ({ height = '100%' }: { height?: string }) => (
|
||||
<ReactECharts option={option} style={{ height, width: '100%' }} onEvents={onEvents} opts={{ renderer: 'canvas' }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* 标题行 */}
|
||||
<HStack spacing={2} mb={1} justify="space-between" flexShrink={0}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Network} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="sm" fontWeight="600" color="rgba(255, 255, 255, 0.9)" letterSpacing="0.5px">
|
||||
板块关联图
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 全屏按钮 */}
|
||||
<IconButton
|
||||
icon={<Maximize2 size={14} />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
_hover={{ color: goldColors.primary, bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||
onClick={onOpen}
|
||||
aria-label="全屏查看"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 主内容区:左侧图例 + 右侧网络图 */}
|
||||
<HStack flex={1} minH={0} spacing={2} align="stretch" overflow="hidden">
|
||||
{/* 左侧图例 - 紧凑型 */}
|
||||
<VStack py={2} pr={2} spacing={1} align="start" borderRight="1px solid rgba(255, 255, 255, 0.06)" flexShrink={0}>
|
||||
{Object.entries(categoryColors).map(([category, color]) => (
|
||||
<HStack key={category} spacing={1}>
|
||||
<Box w={2} h={2} borderRadius="full" bg={color} flexShrink={0} />
|
||||
<Text fontSize="10px" color="rgba(255, 255, 255, 0.5)">
|
||||
{category}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
{/* 右侧网络图 - 填满剩余空间 */}
|
||||
<Box flex={1} minW={0} minH={0} position="relative">
|
||||
<Box position="absolute" top={0} left={0} right={0} bottom={0}>
|
||||
<NetworkChart height="100%" />
|
||||
</Box>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 全屏 Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="full">
|
||||
<ModalOverlay bg="rgba(0, 0, 0, 0.9)" />
|
||||
<ModalContent
|
||||
bg="rgba(15, 15, 22, 0.98)"
|
||||
border="1px solid rgba(212, 175, 55, 0.2)"
|
||||
borderRadius="20px"
|
||||
m={4}
|
||||
maxH="calc(100vh - 32px)"
|
||||
>
|
||||
<ModalHeader borderBottom="1px solid rgba(255, 255, 255, 0.08)" py={4}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={Network} boxSize={5} color={goldColors.primary} />
|
||||
<Text fontSize="lg" fontWeight="700" color="rgba(255, 255, 255, 0.95)">
|
||||
板块关联图
|
||||
</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="rgba(255, 255, 255, 0.6)" _hover={{ color: goldColors.primary }} />
|
||||
<ModalBody p={5} h="calc(100vh - 140px)">
|
||||
<HStack h="100%" spacing={4} align="stretch">
|
||||
{/* 左侧图例 - 全屏模式更大 */}
|
||||
<VStack py={3} pr={4} borderRight="1px solid rgba(255, 255, 255, 0.08)" spacing={2} align="start" flexShrink={0}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.4)" fontWeight="600" mb={1}>
|
||||
分类图例
|
||||
</Text>
|
||||
{Object.entries(categoryColors).map(([category, color]) => (
|
||||
<HStack key={category} spacing={2}>
|
||||
<Box w={3} h={3} borderRadius="full" bg={color} />
|
||||
<Text fontSize="sm" color="rgba(255, 255, 255, 0.7)">
|
||||
{category}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
{/* 右侧网络图 - 全屏填满容器 */}
|
||||
<Box flex={1} minW={0} minH={0} position="relative">
|
||||
<Box position="absolute" top={0} left={0} right={0} bottom={0}>
|
||||
<ReactECharts
|
||||
option={fullscreenOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onEvents={onEvents}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 关联股票列表 Modal */}
|
||||
<Modal isOpen={isStocksModalOpen} onClose={onStocksModalClose} size="md" isCentered>
|
||||
<ModalOverlay bg="rgba(0, 0, 0, 0.8)" />
|
||||
<ModalContent bg="rgba(15, 15, 22, 0.98)" border="1px solid rgba(212, 175, 55, 0.3)" borderRadius="16px" maxH="70vh">
|
||||
<ModalHeader borderBottom="1px solid rgba(255, 255, 255, 0.08)" py={4}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Network} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="md" fontWeight="700" color="rgba(255, 255, 255, 0.95)">
|
||||
{selectedLink?.source}{' '}
|
||||
<Text as="span" color={goldColors.light}>
|
||||
↔
|
||||
</Text>{' '}
|
||||
{selectedLink?.target}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
共 {selectedLink?.stocks?.length || 0} 只关联股票
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="rgba(255, 255, 255, 0.6)" _hover={{ color: goldColors.primary }} />
|
||||
<ModalBody py={4} overflowY="auto">
|
||||
<VStack spacing={2} align="stretch">
|
||||
{selectedLink?.stocks?.map((stock, index) => (
|
||||
<HStack
|
||||
key={stock.stock_code}
|
||||
px={3}
|
||||
py={2}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="8px"
|
||||
border="1px solid rgba(255, 255, 255, 0.06)"
|
||||
_hover={{
|
||||
bg: 'rgba(212, 175, 55, 0.08)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
}}
|
||||
transition="all 0.15s"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.4)" fontWeight="500" w="20px">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<Link
|
||||
href={`/company?scode=${stock.stock_code}`}
|
||||
fontWeight="600"
|
||||
fontSize="sm"
|
||||
color={goldColors.light}
|
||||
_hover={{ color: goldColors.primary }}
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Link>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge fontSize="10px" px={2} py={0.5} bg="rgba(255, 255, 255, 0.06)" color="rgba(255, 255, 255, 0.6)" borderRadius="md">
|
||||
{stock.stock_code}
|
||||
</Badge>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
cursor="pointer"
|
||||
_hover={{ color: goldColors.primary }}
|
||||
onClick={() => window.open(`/company?scode=${stock.stock_code}`, '_blank')}
|
||||
>
|
||||
详情
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SectorNetwork.displayName = 'SectorNetwork';
|
||||
|
||||
export default SectorNetwork;
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 板块热力图 - Treemap 可视化
|
||||
* 展示各板块涨停数量分布,颜色深浅表示热度
|
||||
*
|
||||
* 优化说明:
|
||||
* - squareRatio 调整为黄金比例(约 0.618),使布局更紧凑美观
|
||||
* - 热力图不再拉伸成超长条形,视觉中心更集中
|
||||
* - 使用 memo 优化性能,避免父组件重渲染触发
|
||||
* - HEAT_LEVELS 配置集中管理
|
||||
*/
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { Box, Text, HStack, Icon } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
|
||||
// 导入共享类型
|
||||
import type { SectorInfo, Stock } from '../types';
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface SectorTreemapProps {
|
||||
sectorData?: Record<string, SectorInfo>;
|
||||
onSectorClick?: (sectorName: string) => void;
|
||||
selectedSector?: string | null;
|
||||
}
|
||||
|
||||
interface TreemapDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
stocks: Stock[];
|
||||
netInflow: string;
|
||||
leadingStock: string;
|
||||
}
|
||||
|
||||
interface HeatLevel {
|
||||
threshold: number;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 黑金主题色系
|
||||
const goldColors = {
|
||||
primary: '#D4AF37',
|
||||
light: '#F4D03F',
|
||||
dark: '#B8860B',
|
||||
glow: 'rgba(212, 175, 55, 0.6)',
|
||||
};
|
||||
|
||||
// 热度级别配置 - 集中管理
|
||||
const HEAT_LEVELS: HeatLevel[] = [
|
||||
{ threshold: 0.7, color: '#ef4444', label: '高热度' },
|
||||
{ threshold: 0.4, color: '#f97316', label: '中热度' },
|
||||
{ threshold: 0.2, color: '#eab308', label: '低热度' },
|
||||
{ threshold: 0, color: '#22c55e', label: '冷门' },
|
||||
];
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/**
|
||||
* 根据数值比例获取热度颜色
|
||||
*/
|
||||
const getHeatColor = (value: number, max: number): string => {
|
||||
if (max === 0) return HEAT_LEVELS[HEAT_LEVELS.length - 1].color;
|
||||
const ratio = value / max;
|
||||
return HEAT_LEVELS.find((l) => ratio > l.threshold)?.color || '#22c55e';
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建 Tooltip 格式化函数
|
||||
*/
|
||||
const createTooltipFormatter = (goldColor: string) => {
|
||||
return (params: { name: string; value: number; data: TreemapDataItem }) => {
|
||||
const { name, value, data } = params;
|
||||
const inflow = parseFloat(data.netInflow);
|
||||
const inflowColor = inflow >= 0 ? '#ef4444' : '#22c55e';
|
||||
const inflowSign = inflow >= 0 ? '+' : '';
|
||||
return `
|
||||
<div style="font-weight: 600; margin-bottom: 8px; color: ${goldColor};">${name}</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: rgba(255,255,255,0.6);">涨停数量:</span>
|
||||
<span style="font-weight: 600; color: #ef4444; margin-left: 16px;">${value} 只</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: rgba(255,255,255,0.6);">主力净流入:</span>
|
||||
<span style="font-weight: 600; color: ${inflowColor}; margin-left: 16px;">${inflowSign}${inflow} 亿</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: rgba(255,255,255,0.6);">领涨股:</span>
|
||||
<span style="color: #60a5fa; margin-left: 16px;">${data.leadingStock}</span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
};
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
const SectorTreemap: React.FC<SectorTreemapProps> = memo(
|
||||
({ sectorData = {}, onSectorClick, selectedSector }) => {
|
||||
// 将 sectorData 转换为 Treemap 数据格式
|
||||
const treemapData = useMemo<TreemapDataItem[]>(() => {
|
||||
if (!sectorData || Object.keys(sectorData).length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(sectorData)
|
||||
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||
.map(([name, info]) => ({
|
||||
name,
|
||||
value: info.count || 0,
|
||||
stocks: info.stocks || [],
|
||||
netInflow: String(info.net_inflow ?? (Math.random() * 20 - 5).toFixed(2)),
|
||||
leadingStock: info.leading_stock || info.stocks?.[0]?.sname || '-',
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [sectorData]);
|
||||
|
||||
// ECharts 配置
|
||||
const option = useMemo(() => {
|
||||
if (treemapData.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...treemapData.map((d) => d.value));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(15, 15, 22, 0.95)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: [12, 16],
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 13,
|
||||
},
|
||||
formatter: createTooltipFormatter(goldColors.primary),
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'treemap',
|
||||
roam: false,
|
||||
nodeClick: 'link',
|
||||
breadcrumb: { show: false },
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
squareRatio: 0.618,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { name: string; value: number }) => {
|
||||
const { name, value } = params;
|
||||
if (value < maxValue * 0.1) {
|
||||
return `{small|${name}}`;
|
||||
}
|
||||
return `{name|${name}}\n{value|${value}只}`;
|
||||
},
|
||||
rich: {
|
||||
name: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.5)',
|
||||
lineHeight: 22,
|
||||
},
|
||||
value: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
lineHeight: 18,
|
||||
},
|
||||
small: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
},
|
||||
position: 'insideCenter',
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderWidth: 2,
|
||||
gapWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: goldColors.primary,
|
||||
borderWidth: 3,
|
||||
shadowBlur: 20,
|
||||
shadowColor: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
label: {
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
data: treemapData.map((item) => {
|
||||
const isSelected = selectedSector === item.name;
|
||||
const hasSelection = !!selectedSector;
|
||||
|
||||
return {
|
||||
...item,
|
||||
itemStyle: {
|
||||
color: getHeatColor(item.value, maxValue),
|
||||
borderColor: isSelected ? goldColors.primary : 'rgba(0, 0, 0, 0.3)',
|
||||
borderWidth: isSelected ? 4 : 2,
|
||||
shadowBlur: isSelected ? 15 : 0,
|
||||
shadowColor: isSelected ? goldColors.glow : 'transparent',
|
||||
opacity: !hasSelection || isSelected ? 1 : 0.4,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [treemapData, selectedSector]);
|
||||
|
||||
// 点击事件
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
click: (params: { data?: TreemapDataItem }) => {
|
||||
if (params.data && onSectorClick) {
|
||||
onSectorClick(params.data.name);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[onSectorClick]
|
||||
);
|
||||
|
||||
// 空状态
|
||||
if (treemapData.length === 0) {
|
||||
return (
|
||||
<Box h="100%" display="flex" alignItems="center" justifyContent="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Text>暂无板块数据</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" position="relative">
|
||||
{/* 标题 */}
|
||||
<HStack spacing={2} mb={3}>
|
||||
<Icon as={TrendingUp} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="sm" fontWeight="600" color="rgba(255, 255, 255, 0.9)" letterSpacing="0.5px">
|
||||
板块热力图
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 图例 - 基于 HEAT_LEVELS 配置生成 */}
|
||||
<HStack spacing={4} mb={3} fontSize="xs">
|
||||
{HEAT_LEVELS.map(({ color, label }) => (
|
||||
<HStack key={label} spacing={1}>
|
||||
<Box w={3} h={3} borderRadius="2px" bg={color} />
|
||||
<Text color="rgba(255, 255, 255, 0.6)">{label}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* Treemap 图表 */}
|
||||
<Box h="calc(100% - 60px)">
|
||||
<ReactECharts option={option} style={{ height: '100%', width: '100%' }} onEvents={onEvents} opts={{ renderer: 'canvas' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SectorTreemap.displayName = 'SectorTreemap';
|
||||
|
||||
export default SectorTreemap;
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 涨停时间分布图 - 面积图
|
||||
* 展示全天涨停时间分布情况
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Box, Text, HStack, VStack, Icon } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { Clock, Sunrise, Sun, Sunset, LucideIcon } from 'lucide-react';
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface Stock {
|
||||
formatted_time?: string;
|
||||
zt_time?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SectorInfo {
|
||||
stocks?: Stock[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SummaryData {
|
||||
zt_time_distribution?: TimeDistribution;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TimeDistribution {
|
||||
morning: number;
|
||||
midday: number;
|
||||
afternoon: number;
|
||||
}
|
||||
|
||||
interface TimePeriod {
|
||||
key: keyof TimeDistribution;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
range: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TimeDistributionChartProps {
|
||||
summaryData?: SummaryData;
|
||||
stocks?: Stock[];
|
||||
dateStr?: string;
|
||||
totalStocks?: number;
|
||||
selectedSector?: string | null;
|
||||
sectorData?: Record<string, SectorInfo>;
|
||||
}
|
||||
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 黑金主题色系
|
||||
const goldColors = {
|
||||
primary: '#D4AF37',
|
||||
light: '#F4D03F',
|
||||
dark: '#B8860B',
|
||||
};
|
||||
|
||||
// 时间段定义
|
||||
const TIME_PERIODS: TimePeriod[] = [
|
||||
{
|
||||
key: 'morning',
|
||||
label: '早盘',
|
||||
icon: Sunrise,
|
||||
range: '9:30-11:30',
|
||||
color: '#f97316',
|
||||
},
|
||||
{
|
||||
key: 'midday',
|
||||
label: '午盘',
|
||||
icon: Sun,
|
||||
range: '11:30-13:00',
|
||||
color: '#eab308',
|
||||
},
|
||||
{
|
||||
key: 'afternoon',
|
||||
label: '尾盘',
|
||||
icon: Sunset,
|
||||
range: '13:00-15:00',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
];
|
||||
|
||||
// 时间槽定义
|
||||
const TIME_SLOTS = [
|
||||
'09:30',
|
||||
'09:45',
|
||||
'10:00',
|
||||
'10:15',
|
||||
'10:30',
|
||||
'10:45',
|
||||
'11:00',
|
||||
'11:15',
|
||||
'11:30',
|
||||
'13:00',
|
||||
'13:15',
|
||||
'13:30',
|
||||
'13:45',
|
||||
'14:00',
|
||||
'14:15',
|
||||
'14:30',
|
||||
'14:45',
|
||||
'15:00',
|
||||
];
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
const TimeDistributionChart: React.FC<TimeDistributionChartProps> = memo(
|
||||
({ summaryData = {}, stocks = [], dateStr = '', totalStocks = 0, selectedSector, sectorData = {} }) => {
|
||||
// 格式化日期显示 (YYYYMMDD -> YYYY年M月D日)
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!dateStr || dateStr.length !== 8) return '';
|
||||
const year = dateStr.slice(0, 4);
|
||||
const month = parseInt(dateStr.slice(4, 6), 10);
|
||||
const day = parseInt(dateStr.slice(6, 8), 10);
|
||||
return `${year}年${month}月${day}日`;
|
||||
}, [dateStr]);
|
||||
|
||||
// 从 summary 或 stocks 中提取时间分布数据
|
||||
const timeDistribution = useMemo<TimeDistribution>(() => {
|
||||
// 优先使用 summary 中的统计数据
|
||||
if (summaryData.zt_time_distribution) {
|
||||
return summaryData.zt_time_distribution;
|
||||
}
|
||||
|
||||
// 否则从 stocks 中计算
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return { morning: 0, midday: 0, afternoon: 0 };
|
||||
}
|
||||
|
||||
const distribution: TimeDistribution = { morning: 0, midday: 0, afternoon: 0 };
|
||||
|
||||
stocks.forEach((stock) => {
|
||||
const timeStr = stock.formatted_time || stock.zt_time?.split(' ')[1] || '';
|
||||
const [hour, minute] = timeStr.split(':').map(Number);
|
||||
|
||||
if (isNaN(hour)) return;
|
||||
|
||||
const timeValue = hour + (minute || 0) / 60;
|
||||
|
||||
if (timeValue < 11.5) {
|
||||
distribution.morning++;
|
||||
} else if (timeValue < 13) {
|
||||
distribution.midday++;
|
||||
} else {
|
||||
distribution.afternoon++;
|
||||
}
|
||||
});
|
||||
|
||||
return distribution;
|
||||
}, [summaryData, stocks]);
|
||||
|
||||
// 生成更细粒度的时间分布(用于面积图)
|
||||
const chartData = useMemo(() => {
|
||||
const times: string[] = [];
|
||||
const values: number[] = [];
|
||||
|
||||
const { morning, midday, afternoon } = timeDistribution;
|
||||
const total = morning + midday + afternoon;
|
||||
|
||||
if (total === 0) {
|
||||
return { times: TIME_SLOTS, values: TIME_SLOTS.map(() => 0) };
|
||||
}
|
||||
|
||||
// 模拟分布曲线
|
||||
TIME_SLOTS.forEach((time, index) => {
|
||||
times.push(time);
|
||||
|
||||
let baseValue: number;
|
||||
if (index < 9) {
|
||||
// 早盘:开盘高峰,然后逐渐降低
|
||||
const earlyRatio = index < 3 ? 0.3 : 0.15;
|
||||
baseValue = morning * earlyRatio * (1 - index * 0.05);
|
||||
} else {
|
||||
// 午后:相对平稳,尾盘略有上升
|
||||
const lateRatio = index > 14 ? 0.2 : 0.12;
|
||||
baseValue = afternoon * lateRatio * (1 + (index - 9) * 0.03);
|
||||
}
|
||||
|
||||
// 添加一些随机波动
|
||||
values.push(Math.max(0, Math.round(baseValue * (0.8 + Math.random() * 0.4))));
|
||||
});
|
||||
|
||||
return { times, values };
|
||||
}, [timeDistribution]);
|
||||
|
||||
// 计算选中板块的时间分布(用于叠加曲线)
|
||||
const sectorChartData = useMemo(() => {
|
||||
if (!selectedSector || !sectorData[selectedSector]?.stocks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sectorStocks = sectorData[selectedSector].stocks || [];
|
||||
if (sectorStocks.length === 0) return null;
|
||||
|
||||
// 统计每个时间段的涨停数
|
||||
const slotCounts: Record<string, number> = {};
|
||||
TIME_SLOTS.forEach((slot) => (slotCounts[slot] = 0));
|
||||
|
||||
sectorStocks.forEach((stock) => {
|
||||
const timeStr = stock.formatted_time || stock.zt_time?.split(' ')[1] || '';
|
||||
const [hour, minute] = timeStr.split(':').map(Number);
|
||||
|
||||
if (isNaN(hour)) return;
|
||||
|
||||
// 找到最近的时间槽
|
||||
const timeValue = hour * 60 + (minute || 0);
|
||||
let closestSlot = TIME_SLOTS[0];
|
||||
let minDiff = Infinity;
|
||||
|
||||
TIME_SLOTS.forEach((slot) => {
|
||||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||||
const slotValue = slotHour * 60 + slotMinute;
|
||||
const diff = Math.abs(timeValue - slotValue);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closestSlot = slot;
|
||||
}
|
||||
});
|
||||
|
||||
slotCounts[closestSlot]++;
|
||||
});
|
||||
|
||||
const values = TIME_SLOTS.map((slot) => slotCounts[slot]);
|
||||
|
||||
return { times: TIME_SLOTS, values };
|
||||
}, [selectedSector, sectorData]);
|
||||
|
||||
// ECharts 配置
|
||||
const option = useMemo(() => {
|
||||
const { times, values } = chartData;
|
||||
const hasSelection = !!selectedSector && sectorChartData;
|
||||
|
||||
// 构建 series 数组
|
||||
const series: object[] = [
|
||||
// 全市场曲线
|
||||
{
|
||||
name: '全市场',
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
color: hasSelection ? 'rgba(212, 175, 55, 0.3)' : goldColors.primary,
|
||||
width: hasSelection ? 1.5 : 2,
|
||||
},
|
||||
itemStyle: {
|
||||
color: hasSelection ? 'rgba(212, 175, 55, 0.3)' : goldColors.primary,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: hasSelection
|
||||
? [
|
||||
{ offset: 0, color: 'rgba(212, 175, 55, 0.15)' },
|
||||
{ offset: 1, color: 'rgba(212, 175, 55, 0.02)' },
|
||||
]
|
||||
: [
|
||||
{ offset: 0, color: 'rgba(212, 175, 55, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(212, 175, 55, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: goldColors.light,
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10,
|
||||
shadowColor: goldColors.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 添加选中板块曲线
|
||||
if (hasSelection && sectorChartData) {
|
||||
series.push({
|
||||
name: selectedSector,
|
||||
type: 'line',
|
||||
data: sectorChartData.values,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
color: '#ef4444',
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ef4444',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(239, 68, 68, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(239, 68, 68, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: '#f87171',
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10,
|
||||
shadowColor: '#ef4444',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 15, 22, 0.95)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: [10, 14],
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 12,
|
||||
},
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
lineStyle: {
|
||||
color: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
},
|
||||
formatter: (params: Array<{ name: string; seriesName: string; value: number }>) => {
|
||||
let result = `<span style="color: rgba(255,255,255,0.6);">${params[0].name}</span><br/>`;
|
||||
params.forEach((param) => {
|
||||
const color = param.seriesName === '全市场' ? goldColors.primary : '#ef4444';
|
||||
result += `<span style="color: ${color};">${param.seriesName}</span>: <span style="font-weight: 600;">${param.value} 只</span><br/>`;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
legend: hasSelection
|
||||
? {
|
||||
show: true,
|
||||
top: 0,
|
||||
right: 0,
|
||||
textStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 10,
|
||||
},
|
||||
itemWidth: 12,
|
||||
itemHeight: 8,
|
||||
}
|
||||
: { show: false },
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: hasSelection ? 25 : 20,
|
||||
bottom: 30,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
fontSize: 10,
|
||||
interval: 2,
|
||||
},
|
||||
splitLine: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
fontSize: 10,
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: series,
|
||||
};
|
||||
}, [chartData, selectedSector, sectorChartData]);
|
||||
|
||||
const total = timeDistribution.morning + timeDistribution.midday + timeDistribution.afternoon;
|
||||
|
||||
return (
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* 标题行 */}
|
||||
<HStack spacing={2} mb={3}>
|
||||
<Icon as={Clock} boxSize={4} color={goldColors.primary} />
|
||||
<Text fontSize="sm" fontWeight="600" color="rgba(255, 255, 255, 0.9)" letterSpacing="0.5px">
|
||||
涨停时间分布
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
(副标题: 全天情绪进攻节奏)
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<HStack spacing={2} mb={3}>
|
||||
{TIME_PERIODS.map(({ key, label, icon: IconComp, color }) => {
|
||||
const count = timeDistribution[key] || 0;
|
||||
const percent = total > 0 ? ((count / total) * 100).toFixed(0) : 0;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
key={key}
|
||||
flex={1}
|
||||
spacing={0}
|
||||
p={2}
|
||||
borderRadius="8px"
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
border="1px solid rgba(255, 255, 255, 0.06)"
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={IconComp} boxSize={3} color={color} />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="md" fontWeight="700" color={color}>
|
||||
{count}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.4)">
|
||||
{percent}%
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
{/* 面积图 */}
|
||||
<Box flex={1} minH={0}>
|
||||
{total > 0 ? (
|
||||
<ReactECharts option={option} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
||||
) : (
|
||||
<VStack h="100%" justify="center" color="rgba(255, 255, 255, 0.4)">
|
||||
<Icon as={Clock} boxSize={8} />
|
||||
<Text fontSize="sm">暂无时间分布数据</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TimeDistributionChart.displayName = 'TimeDistributionChart';
|
||||
|
||||
export default TimeDistributionChart;
|
||||
287
src/views/LimitAnalyse/components/MarketPanorama/index.tsx
Normal file
287
src/views/LimitAnalyse/components/MarketPanorama/index.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 市场全景与板块分析模块
|
||||
* 核心围绕"板块(Sector)"和"市场情绪(Sentiment)"展开
|
||||
*
|
||||
* 设计理念:先看面(宏观)→ 再看线(逻辑)→ 最后看点(具体标的)
|
||||
*
|
||||
* 布局结构(左右双栏):
|
||||
* - 左侧 (60%):板块热力图 + 板块异动明细
|
||||
* - 右侧 (40%):板块关联图/板块分布 + 热门概念词云 + 高位股统计
|
||||
*/
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Box, VStack, HStack, Text, Icon, Tooltip } from '@chakra-ui/react';
|
||||
import { Flame, BarChart3, AlertTriangle } from 'lucide-react';
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||
|
||||
// 导入子组件
|
||||
import SectorTreemap from './components/SectorTreemap';
|
||||
import MacroTabPanel from './components/MacroTabPanel';
|
||||
import SectorNetwork from './components/SectorNetwork';
|
||||
import SectorMovementTable from '../SectorMovementTable';
|
||||
import HighPositionSidebar from '../HighPositionSidebar';
|
||||
|
||||
// 导入共享类型
|
||||
import type { DailyData, WordCloudItem, SortedSector } from './types';
|
||||
|
||||
interface MarketPanoramaProps {
|
||||
dailyData?: DailyData | null;
|
||||
wordCloudData?: WordCloudItem[];
|
||||
totalStocks?: number;
|
||||
selectedSector?: string | null;
|
||||
onSectorSelect?: (sectorName: string) => void;
|
||||
sortedSectors?: SortedSector[];
|
||||
dateStr?: string;
|
||||
}
|
||||
|
||||
// ============ 常量配置 ============
|
||||
|
||||
// 黑金主题色系
|
||||
const goldColors = {
|
||||
primary: '#D4AF37',
|
||||
light: '#F4D03F',
|
||||
dark: '#B8860B',
|
||||
glow: 'rgba(212, 175, 55, 0.4)',
|
||||
};
|
||||
|
||||
// 动画效果
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
`;
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
`;
|
||||
|
||||
const glow = keyframes`
|
||||
0%, 100% { box-shadow: 0 0 5px ${goldColors.glow}, 0 0 10px ${goldColors.glow}; }
|
||||
50% { box-shadow: 0 0 15px ${goldColors.glow}, 0 0 25px ${goldColors.glow}; }
|
||||
`;
|
||||
|
||||
// 玻璃拟态样式
|
||||
const glassStyle = {
|
||||
bg: 'rgba(15, 15, 22, 0.9)',
|
||||
backdropFilter: `${GLASS_BLUR.lg} saturate(180%)`,
|
||||
border: '1px solid rgba(212, 175, 55, 0.15)',
|
||||
borderRadius: '16px',
|
||||
};
|
||||
|
||||
// 内部卡片样式
|
||||
const innerCardStyle = {
|
||||
bg: 'rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: '12px',
|
||||
p: 3,
|
||||
};
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
const MarketPanorama: React.FC<MarketPanoramaProps> = memo(
|
||||
({ dailyData, wordCloudData = [], totalStocks = 0, selectedSector, onSectorSelect, sortedSectors = [], dateStr = '' }) => {
|
||||
// 解构数据(处理 null/undefined 情况)
|
||||
const safeData = dailyData || {};
|
||||
const { sector_data: sectorData = {}, chart_data: chartData = {}, sector_relations: sectorRelations, summary = {} } = safeData;
|
||||
|
||||
// 计算涨停总数(如果没有传入,从 dailyData 中获取)
|
||||
const actualTotalStocks = totalStocks || safeData.total_stocks || 0;
|
||||
|
||||
// 板块点击处理 - 直接调用外部回调
|
||||
const handleSectorClick = useCallback(
|
||||
(sectorName: string) => {
|
||||
if (onSectorSelect) {
|
||||
onSectorSelect(sectorName);
|
||||
}
|
||||
},
|
||||
[onSectorSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box {...glassStyle} position="relative" overflow="hidden" mb={1}>
|
||||
{/* 顶部金色装饰条 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="3px"
|
||||
bgGradient={`linear(to-r, transparent, ${goldColors.primary}, ${goldColors.light}, ${goldColors.primary}, transparent)`}
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
{/* 头部 */}
|
||||
<HStack px={4} py={3} justify="space-between" borderBottom="1px solid rgba(255, 255, 255, 0.08)">
|
||||
{/* 左侧:图标 + 标题 */}
|
||||
<HStack spacing={2.5}>
|
||||
{/* 动态发光图标 */}
|
||||
<Box
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="10px"
|
||||
bg={`linear-gradient(135deg, ${goldColors.dark}, ${goldColors.primary}, ${goldColors.light})`}
|
||||
css={css`
|
||||
animation: ${glow} 2s ease-in-out infinite;
|
||||
`}
|
||||
position="relative"
|
||||
>
|
||||
<Icon as={BarChart3} boxSize={3.5} color="white" />
|
||||
{/* 小火焰装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-2px"
|
||||
right="-2px"
|
||||
css={css`
|
||||
animation: ${pulse} 1.5s ease-in-out infinite;
|
||||
`}
|
||||
>
|
||||
<Icon as={Flame} boxSize={2.5} color="#ef4444" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 主标题 - 渐变金色 */}
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="700"
|
||||
bgGradient={`linear(to-r, ${goldColors.light}, ${goldColors.primary}, ${goldColors.light})`}
|
||||
bgClip="text"
|
||||
letterSpacing="0.5px"
|
||||
css={css`
|
||||
text-shadow: 0 0 30px ${goldColors.glow};
|
||||
background-size: 200% 100%;
|
||||
animation: ${shimmer} 3s linear infinite;
|
||||
`}
|
||||
>
|
||||
市场全景
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:核心指标 */}
|
||||
<HStack spacing={3} fontSize="xs" color="rgba(255, 255, 255, 0.8)">
|
||||
<Text color={goldColors.light} fontWeight="600">
|
||||
{Object.keys(sectorData).length}个板块
|
||||
</Text>
|
||||
<Text color="#ef4444" fontWeight="600">
|
||||
{actualTotalStocks}只涨停
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack align="start" spacing={1.5} p={1}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text color="rgba(255,255,255,0.6)" fontSize="xs">
|
||||
最高连板
|
||||
</Text>
|
||||
<Text fontWeight="bold" color="#ef4444" fontSize="xs">
|
||||
{summary?.max_continuous || 5}板
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text color="rgba(255,255,255,0.6)" fontSize="xs">
|
||||
3板以上
|
||||
</Text>
|
||||
<Text fontWeight="bold" color="#f97316" fontSize="xs">
|
||||
{summary?.high_position_count || 8}只
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text color="rgba(255,255,255,0.6)" fontSize="xs">
|
||||
今日炸板率
|
||||
</Text>
|
||||
<Text fontWeight="bold" color="#f59e0b" fontSize="xs">
|
||||
{summary?.fail_rate || 22}%
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text color="rgba(255,255,255,0.6)" fontSize="xs">
|
||||
高位炸板
|
||||
</Text>
|
||||
<Text fontWeight="bold" color="#8b5cf6" fontSize="xs">
|
||||
{summary?.high_fail_count || 3}只
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
placement="bottom"
|
||||
hasArrow
|
||||
bg="rgba(15, 15, 22, 0.95)"
|
||||
border="1px solid rgba(212, 175, 55, 0.3)"
|
||||
borderRadius="10px"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
<HStack spacing={1} cursor="pointer" _hover={{ opacity: 0.8 }}>
|
||||
<Text color="#f59e0b" fontWeight="600">
|
||||
高位股风险:{' '}
|
||||
{(summary?.high_position_count ?? 0) > 10 ? '高' : (summary?.high_position_count ?? 0) > 5 ? '中' : '低'}
|
||||
</Text>
|
||||
<Icon as={AlertTriangle} boxSize={3} color="#f59e0b" />
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<HStack spacing={3} p={4} align="start" flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
|
||||
{/* ==================== 左侧:热力图 + 板块异动明细 (50%) ==================== */}
|
||||
<VStack flex={{ base: '1 1 100%', lg: '5' }} spacing={3} minW={0}>
|
||||
{/* 上:板块热力图 */}
|
||||
<Box {...innerCardStyle} w="100%" h="280px">
|
||||
<SectorTreemap sectorData={sectorData} onSectorClick={handleSectorClick} selectedSector={selectedSector} />
|
||||
</Box>
|
||||
|
||||
{/* 下:板块异动明细 */}
|
||||
<Box w="100%" h="740px">
|
||||
<SectorMovementTable
|
||||
sortedSectors={sortedSectors}
|
||||
totalStocks={actualTotalStocks}
|
||||
activeSectorCount={sortedSectors.length}
|
||||
selectedSector={selectedSector}
|
||||
onSectorSelect={handleSectorClick}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* ==================== 右侧:关联图/分布 + 词云 + 高位股 (50%) ==================== */}
|
||||
<VStack flex={{ base: '1 1 100%', lg: '5' }} spacing={3} minW={0}>
|
||||
{/* 上:板块关联图 + 板块分布 */}
|
||||
<HStack w="100%" spacing={3} align="stretch">
|
||||
{/* 板块关联图 60% */}
|
||||
<Box {...innerCardStyle} flex="6" h="240px">
|
||||
<SectorNetwork
|
||||
sectorData={sectorData}
|
||||
sectorRelations={sectorRelations}
|
||||
selectedSector={selectedSector}
|
||||
onSectorClick={handleSectorClick}
|
||||
/>
|
||||
</Box>
|
||||
{/* 板块分布 40% */}
|
||||
<Box {...innerCardStyle} flex="4" h="240px">
|
||||
<MacroTabPanel
|
||||
wordCloudData={[]}
|
||||
chartData={chartData}
|
||||
showPieOnly={true}
|
||||
selectedSector={selectedSector}
|
||||
onSectorClick={handleSectorClick}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 中:热门概念词云 */}
|
||||
<Box {...innerCardStyle} w="100%" h="250px">
|
||||
<MacroTabPanel wordCloudData={wordCloudData} chartData={chartData} compact={true} selectedSector={selectedSector} />
|
||||
</Box>
|
||||
|
||||
{/* 下:高位股统计 */}
|
||||
<Box w="100%" h="516px">
|
||||
<HighPositionSidebar dateStr={dateStr} />
|
||||
</Box>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MarketPanorama.displayName = 'MarketPanorama';
|
||||
|
||||
export default MarketPanorama;
|
||||
64
src/views/LimitAnalyse/components/MarketPanorama/types.ts
Normal file
64
src/views/LimitAnalyse/components/MarketPanorama/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* MarketPanorama 组件共享类型定义
|
||||
*/
|
||||
|
||||
export interface Stock {
|
||||
scode?: string;
|
||||
sname?: string;
|
||||
stock_code?: string;
|
||||
stock_name?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
change_pct?: number;
|
||||
core_sectors?: string[];
|
||||
formatted_time?: string;
|
||||
zt_time?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SectorInfo {
|
||||
count?: number;
|
||||
stocks?: Stock[];
|
||||
net_inflow?: number;
|
||||
leading_stock?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels?: string[];
|
||||
counts?: number[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SectorRelation {
|
||||
source: string;
|
||||
target: string;
|
||||
strength?: number;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
max_continuous?: number;
|
||||
high_position_count?: number;
|
||||
fail_rate?: number;
|
||||
high_fail_count?: number;
|
||||
}
|
||||
|
||||
export interface DailyData {
|
||||
sector_data?: Record<string, SectorInfo>;
|
||||
chart_data?: ChartData;
|
||||
sector_relations?: SectorRelation[];
|
||||
summary?: Summary;
|
||||
total_stocks?: number;
|
||||
}
|
||||
|
||||
export interface WordCloudItem {
|
||||
name?: string;
|
||||
text?: string;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface SortedSector {
|
||||
name: string;
|
||||
count: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user