From 412e51fe281966e63e02e9446141fad93d62a959 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 5 Jan 2026 14:19:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(MarketPanorama):=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=20TypeScript=20=E8=BD=AC=E6=8D=A2=E4=B8=8E=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 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 (板块异动表格) --- .../components/MacroTabPanel.tsx | 466 ++++++++++++ .../components/SectorMovementTable.tsx | 334 +++++++++ .../components/SectorNetwork.tsx | 702 ++++++++++++++++++ .../components/SectorTreemap.tsx | 271 +++++++ .../components/TimeDistributionChart.tsx | 473 ++++++++++++ .../components/MarketPanorama/index.tsx | 287 +++++++ .../components/MarketPanorama/types.ts | 64 ++ 7 files changed, 2597 insertions(+) create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/components/MacroTabPanel.tsx create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/components/SectorMovementTable.tsx create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/components/SectorNetwork.tsx create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/components/SectorTreemap.tsx create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/components/TimeDistributionChart.tsx create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/index.tsx create mode 100644 src/views/LimitAnalyse/components/MarketPanorama/types.ts diff --git a/src/views/LimitAnalyse/components/MarketPanorama/components/MacroTabPanel.tsx b/src/views/LimitAnalyse/components/MarketPanorama/components/MacroTabPanel.tsx new file mode 100644 index 00000000..19689982 --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/components/MacroTabPanel.tsx @@ -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 = memo(({ id, icon: IconComponent, label, isActive, onClick }) => ( + onClick(id)} + > + + + {label} + + +)); + +TabButton.displayName = 'TabButton'; + +// ============ 主组件 ============ + +const MacroTabPanel: React.FC = memo( + ({ wordCloudData = [], chartData = {}, compact = false, showPieOnly = false, selectedSector, onSectorClick }) => { + const [activeTab, setActiveTab] = useState('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 `${params.name}
+ 热度: ${params.value}`; + }, + }, + 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 `${params.name}
+ 涨停数: ${params.value} 只
+ 占比: ${params.percent.toFixed(1)}%`; + }, + }, + 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 `${params.name}
+ 涨停数: ${params.value} 只
+ 占比: ${params.percent.toFixed(1)}%`; + }, + }, + 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 ( + + {/* 标题行 */} + + + + 热门概念词云 + + + {/* 词云 */} + + {hasWordCloud ? ( + + ) : ( + + + 暂无词云数据 + + )} + + + ); + } + + // showPieOnly 模式:只显示板块分布饼图 + if (showPieOnly) { + return ( + + {/* 标题 */} + + + + 板块分布 + + + {/* 饼图 */} + + {hasPieData ? ( + + ) : ( + + + 暂无分布数据 + + )} + + + ); + } + + // 默认模式:Tab 切换 + return ( + + {/* Tab 切换 */} + + + + + + {/* 内容区域 */} + + {activeTab === 'wordcloud' ? ( + hasWordCloud ? ( + + ) : ( + + + 暂无词云数据 + + ) + ) : hasPieData ? ( + + ) : ( + + + 暂无分布数据 + + )} + + + ); + } +); + +MacroTabPanel.displayName = 'MacroTabPanel'; + +export default MacroTabPanel; diff --git a/src/views/LimitAnalyse/components/MarketPanorama/components/SectorMovementTable.tsx b/src/views/LimitAnalyse/components/MarketPanorama/components/SectorMovementTable.tsx new file mode 100644 index 00000000..a033e98f --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/components/SectorMovementTable.tsx @@ -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; + 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' ? : ; +}); + +SortIcon.displayName = 'SortIcon'; + +// ============ 主组件 ============ + +const SectorMovementTable: React.FC = memo(({ sectorData = {}, selectedSector, onSectorClick }) => { + const [sortField, setSortField] = useState('count'); + const [sortOrder, setSortOrder] = useState('desc'); + const [expandedRow, setExpandedRow] = useState(null); + + // 处理表格数据 + const tableData = useMemo(() => { + 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 ( + + + 暂无板块数据 + + ); + } + + return ( + + {/* 标题 */} + + + + 板块异动明细 + + + {tableData.length} 个板块 + + + + {/* 表格 */} + + + + + + + + + + + + + {tableData.map((sector, index) => { + const isSelected = selectedSector === sector.name; + const isExpanded = expandedRow === sector.name; + + return ( + + 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" + > + + + + + + + {/* 展开的股票列表 */} + {isExpanded && sector.stocks.length > 0 && ( + + + + )} + + ); + })} + +
handleSort('name')}> + + 板块名称 + + + handleSort('count')} isNumeric> + + 涨停数 + + + handleSort('netInflow')} isNumeric> + + 净流入(亿) + + + + 领涨股 +
+ + {index < 3 && ( + + {index + 1} + + )} + + {sector.name} + + + + + + + + {sector.count} + + + + + = 0 ? ArrowUp : ArrowDown} boxSize={3} color={sector.netInflow >= 0 ? '#ef4444' : '#22c55e'} /> + = 0 ? '#ef4444' : '#22c55e'}> + {sector.netInflow >= 0 ? '+' : ''} + {sector.netInflow.toFixed(2)} + + + + + + + {sector.leadingStock} + + + +{sector.leadingStockChange}% + + + +
+ + + 成分股 ({sector.stocks.length}只) + + + {sector.stocks.slice(0, 8).map((stock, idx) => ( + + {stock.sname || stock.name} + + ))} + {sector.stocks.length > 8 && ( + + +{sector.stocks.length - 8} 只 + + )} + + +
+
+
+
+ ); +}); + +SectorMovementTable.displayName = 'SectorMovementTable'; + +export default SectorMovementTable; diff --git a/src/views/LimitAnalyse/components/MarketPanorama/components/SectorNetwork.tsx b/src/views/LimitAnalyse/components/MarketPanorama/components/SectorNetwork.tsx new file mode 100644 index 00000000..cac1b97a --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/components/SectorNetwork.tsx @@ -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; + 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 = { + 科技: '#3b82f6', // 蓝色 + 新能源: '#22c55e', // 绿色 + 医药: '#06b6d4', // 青色 + 金融: '#eab308', // 金色 + 消费: '#f97316', // 橙色 + 周期: '#ef4444', // 红色 + 其他: '#8b5cf6', // 紫色 +}; + +// 分类顺序(用于聚合排列) +const categoryOrder = ['科技', '新能源', '医药', '金融', '消费', '周期', '其他']; + +// 分类关键词映射 +const categoryKeywords: Record = { + 科技: ['人工智能', '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 => { + if (!sectorName) return new Set(); + const related = new Set(); + 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 = memo(({ sectorData = {}, sectorRelations, selectedSector, onSectorClick }) => { + // 连线点击状态 + const [selectedLink, setSelectedLink] = useState(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(); + 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(); + const linkStrength = new Map(); // 记录关联强度 + const linkStocks = new Map(); // 记录两板块交集股票 + + // 基于股票重叠计算关联强度 + 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 => { + 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 `
+ ${params.name} + ${category}
+ 涨停数: ${params.value} 只 +
`; + } + // 连线 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 `
+
+ ${linkData.source} ${linkData.target} +
+
+ 关联股票(${stockCount}只): +
+
+ ${displayStocks}${hasMore ? '...' : ''} +
+ ${ + stockCount > 0 + ? `
+ 👆 点击连线查看全部 +
` + : '' + } +
`; + } + 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 ( + + + 暂无关联数据 + + ); + } + + // 网络图组件(复用于普通和全屏模式) + const NetworkChart = ({ height = '100%' }: { height?: string }) => ( + + ); + + return ( + + {/* 标题行 */} + + + + + 板块关联图 + + + {/* 全屏按钮 */} + } + 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="全屏查看" + /> + + + {/* 主内容区:左侧图例 + 右侧网络图 */} + + {/* 左侧图例 - 紧凑型 */} + + {Object.entries(categoryColors).map(([category, color]) => ( + + + + {category} + + + ))} + + + {/* 右侧网络图 - 填满剩余空间 */} + + + + + + + + {/* 全屏 Modal */} + + + + + + + + 板块关联图 + + + + + + + {/* 左侧图例 - 全屏模式更大 */} + + + 分类图例 + + {Object.entries(categoryColors).map(([category, color]) => ( + + + + {category} + + + ))} + + + {/* 右侧网络图 - 全屏填满容器 */} + + + + + + + + + + + {/* 关联股票列表 Modal */} + + + + + + + + + {selectedLink?.source}{' '} + + ↔ + {' '} + {selectedLink?.target} + + + + 共 {selectedLink?.stocks?.length || 0} 只关联股票 + + + + + + + {selectedLink?.stocks?.map((stock, index) => ( + + + + {index + 1} + + + {stock.stock_name} + + + + + {stock.stock_code} + + window.open(`/company?scode=${stock.stock_code}`, '_blank')} + > + 详情 + + + + ))} + + + + + + ); +}); + +SectorNetwork.displayName = 'SectorNetwork'; + +export default SectorNetwork; diff --git a/src/views/LimitAnalyse/components/MarketPanorama/components/SectorTreemap.tsx b/src/views/LimitAnalyse/components/MarketPanorama/components/SectorTreemap.tsx new file mode 100644 index 00000000..30cfb2dc --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/components/SectorTreemap.tsx @@ -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; + 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 ` +
${name}
+
+ 涨停数量: + ${value} 只 +
+
+ 主力净流入: + ${inflowSign}${inflow} 亿 +
+
+ 领涨股: + ${data.leadingStock} +
+ `; + }; +}; + +// ============ 主组件 ============ + +const SectorTreemap: React.FC = memo( + ({ sectorData = {}, onSectorClick, selectedSector }) => { + // 将 sectorData 转换为 Treemap 数据格式 + const treemapData = useMemo(() => { + 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 ( + + 暂无板块数据 + + ); + } + + return ( + + {/* 标题 */} + + + + 板块热力图 + + + + {/* 图例 - 基于 HEAT_LEVELS 配置生成 */} + + {HEAT_LEVELS.map(({ color, label }) => ( + + + {label} + + ))} + + + {/* Treemap 图表 */} + + + + + ); + } +); + +SectorTreemap.displayName = 'SectorTreemap'; + +export default SectorTreemap; diff --git a/src/views/LimitAnalyse/components/MarketPanorama/components/TimeDistributionChart.tsx b/src/views/LimitAnalyse/components/MarketPanorama/components/TimeDistributionChart.tsx new file mode 100644 index 00000000..b138eb33 --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/components/TimeDistributionChart.tsx @@ -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; +} + +// ============ 常量配置 ============ + +// 黑金主题色系 +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 = 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(() => { + // 优先使用 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 = {}; + 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 = `${params[0].name}
`; + params.forEach((param) => { + const color = param.seriesName === '全市场' ? goldColors.primary : '#ef4444'; + result += `${param.seriesName}: ${param.value} 只
`; + }); + 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 ( + + {/* 标题行 */} + + + + 涨停时间分布 + + + (副标题: 全天情绪进攻节奏) + + + + {/* 统计卡片 */} + + {TIME_PERIODS.map(({ key, label, icon: IconComp, color }) => { + const count = timeDistribution[key] || 0; + const percent = total > 0 ? ((count / total) * 100).toFixed(0) : 0; + + return ( + + + + + {label} + + + + {count} + + + {percent}% + + + ); + })} + + + {/* 面积图 */} + + {total > 0 ? ( + + ) : ( + + + 暂无时间分布数据 + + )} + + + ); + } +); + +TimeDistributionChart.displayName = 'TimeDistributionChart'; + +export default TimeDistributionChart; diff --git a/src/views/LimitAnalyse/components/MarketPanorama/index.tsx b/src/views/LimitAnalyse/components/MarketPanorama/index.tsx new file mode 100644 index 00000000..19c80578 --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/index.tsx @@ -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 = 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 ( + + {/* 顶部金色装饰条 */} + + + {/* 头部 */} + + {/* 左侧:图标 + 标题 */} + + {/* 动态发光图标 */} + + + {/* 小火焰装饰 */} + + + + + + {/* 主标题 - 渐变金色 */} + + 市场全景 + + + + {/* 右侧:核心指标 */} + + + {Object.keys(sectorData).length}个板块 + + + {actualTotalStocks}只涨停 + + + + + 最高连板 + + + {summary?.max_continuous || 5}板 + + + + + 3板以上 + + + {summary?.high_position_count || 8}只 + + + + + 今日炸板率 + + + {summary?.fail_rate || 22}% + + + + + 高位炸板 + + + {summary?.high_fail_count || 3}只 + + + + } + 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} + > + + + 高位股风险:{' '} + {(summary?.high_position_count ?? 0) > 10 ? '高' : (summary?.high_position_count ?? 0) > 5 ? '中' : '低'} + + + + + + + + {/* 内容区域 */} + + {/* ==================== 左侧:热力图 + 板块异动明细 (50%) ==================== */} + + {/* 上:板块热力图 */} + + + + + {/* 下:板块异动明细 */} + + + + + + {/* ==================== 右侧:关联图/分布 + 词云 + 高位股 (50%) ==================== */} + + {/* 上:板块关联图 + 板块分布 */} + + {/* 板块关联图 60% */} + + + + {/* 板块分布 40% */} + + + + + + {/* 中:热门概念词云 */} + + + + + {/* 下:高位股统计 */} + + + + + + + ); + } +); + +MarketPanorama.displayName = 'MarketPanorama'; + +export default MarketPanorama; diff --git a/src/views/LimitAnalyse/components/MarketPanorama/types.ts b/src/views/LimitAnalyse/components/MarketPanorama/types.ts new file mode 100644 index 00000000..7d16f823 --- /dev/null +++ b/src/views/LimitAnalyse/components/MarketPanorama/types.ts @@ -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; + 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; +}