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:
zdl
2026-01-05 14:19:56 +08:00
parent d16938de9e
commit 412e51fe28
7 changed files with 2597 additions and 0 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;
}