diff --git a/src/views/StockOverview/components/MarketHeatmap/components/HeatmapLegend.tsx b/src/views/StockOverview/components/MarketHeatmap/components/HeatmapLegend.tsx new file mode 100644 index 00000000..04d03ab3 --- /dev/null +++ b/src/views/StockOverview/components/MarketHeatmap/components/HeatmapLegend.tsx @@ -0,0 +1,50 @@ +/** + * HeatmapLegend - 热力图图例组件 + */ +import React, { memo } from 'react'; +import { HStack, Box, Text } from '@chakra-ui/react'; +import { COLORS } from '../styles'; + +interface LegendItem { + color: string; + label: string; + glow?: boolean; +} + +const LEGEND_ITEMS: LegendItem[] = [ + { color: COLORS.up, label: '上涨', glow: true }, + { color: COLORS.neutral, label: '平盘' }, + { color: COLORS.down, label: '下跌', glow: true }, +]; + +const HeatmapLegend: React.FC = memo(() => { + return ( + + {LEGEND_ITEMS.map(({ color, label, glow }) => ( + + + + {label} + + + ))} + + ); +}); + +HeatmapLegend.displayName = 'HeatmapLegend'; + +export default HeatmapLegend; diff --git a/src/views/StockOverview/components/MarketHeatmap/components/index.ts b/src/views/StockOverview/components/MarketHeatmap/components/index.ts new file mode 100644 index 00000000..24020838 --- /dev/null +++ b/src/views/StockOverview/components/MarketHeatmap/components/index.ts @@ -0,0 +1 @@ +export { default as HeatmapLegend } from './HeatmapLegend'; diff --git a/src/views/StockOverview/components/MarketHeatmap/index.tsx b/src/views/StockOverview/components/MarketHeatmap/index.tsx new file mode 100644 index 00000000..df06e3c9 --- /dev/null +++ b/src/views/StockOverview/components/MarketHeatmap/index.tsx @@ -0,0 +1,192 @@ +/** + * MarketHeatmap - 市值热力图组件 + * 使用 ECharts treemap 展示 A 股市场全景 + * + * 优化点: + * 1. 类型拆分到 types.ts + * 2. 样式常量拆分到 styles.ts + * 3. 工具函数拆分到 utils.ts + * 4. Legend 拆分为原子组件 + */ +import React, { useRef, useEffect, useCallback, memo, useMemo } from 'react'; +import { Box, Center, VStack, Spinner, Text } from '@chakra-ui/react'; +import { echarts } from '@lib/echarts'; + +import type { MarketHeatmapProps, TreeNodeData } from './types'; +import { HeatmapLegend } from './components'; +import { COLORS, CHART_CONFIG, LABEL_DISPLAY_THRESHOLD } from './styles'; +import { buildTreeData, formatGroupTooltip, formatStockTooltip } from './utils'; + +const MarketHeatmap: React.FC = ({ + data = [], + height = '400px', + loading = false, + onStockClick, + showLegend = true, +}) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + // 缓存树图数据构建 + const treeData = useMemo(() => { + if (!data || data.length === 0) return []; + return buildTreeData(data); + }, [data]); + + // 缓存 ECharts 配置 + const chartOption = useMemo(() => { + if (treeData.length === 0) return null; + + return { + backgroundColor: COLORS.chartBg, + tooltip: { + backgroundColor: COLORS.tooltipBg, + borderColor: COLORS.gold, + borderWidth: 2, + textStyle: { color: 'white' }, + formatter: (info: { data: TreeNodeData }) => { + const d = info.data; + if (d.children) { + return formatGroupTooltip(d); + } + return formatStockTooltip(d); + }, + }, + series: [ + { + name: 'A股市场', + type: 'treemap', + data: treeData, + leafDepth: 1, + roam: false, + left: 0, + right: 0, + top: CHART_CONFIG.topOffset, + bottom: 0, + breadcrumb: { + show: true, + top: 5, + left: 5, + height: CHART_CONFIG.breadcrumbHeight, + itemStyle: { + color: COLORS.breadcrumbBg, + borderColor: COLORS.gold, + borderWidth: 1, + shadowBlur: 5, + shadowColor: `${COLORS.gold}40`, + textStyle: { color: COLORS.gold, fontSize: 11 }, + }, + emphasis: { + itemStyle: { + color: COLORS.gold, + textStyle: { color: COLORS.chartBg }, + }, + }, + }, + levels: [ + { + itemStyle: { + borderColor: 'transparent', + borderWidth: 0, + gapWidth: 0, + }, + upperLabel: { show: false }, + }, + { + itemStyle: { + borderColor: CHART_CONFIG.borderColor, + borderWidth: CHART_CONFIG.borderWidth, + gapWidth: 0, + }, + }, + ], + itemStyle: { + borderColor: CHART_CONFIG.borderColor, + borderWidth: CHART_CONFIG.borderWidth, + gapWidth: 0, + }, + label: { + show: true, + formatter: (params: { data: TreeNodeData; name: string }) => { + const d = params.data; + if (d.children) return params.name; + return (d.value || 0) > LABEL_DISPLAY_THRESHOLD ? d.name : ''; + }, + fontSize: 11, + color: 'white', + textShadowColor: 'rgba(0, 0, 0, 0.8)', + textShadowBlur: 3, + }, + }, + ], + }; + }, [treeData]); + + // 渲染热力图 + const renderChart = useCallback(() => { + if (!chartRef.current || !chartOption) return; + + // 初始化或获取 ECharts 实例 + if (!chartInstance.current) { + chartInstance.current = echarts.init(chartRef.current, 'dark'); + } + + chartInstance.current.setOption(chartOption); + + // 点击事件 + chartInstance.current.off('click'); + chartInstance.current.on('click', (params) => { + const data = params.data as TreeNodeData | undefined; + if (data && data.code && !data.children) { + onStockClick?.(data.code, data.name); + } + }); + }, [chartOption, onStockClick]); + + // 初始化和更新 + useEffect(() => { + if (!loading && data.length > 0) { + // 销毁旧实例 + if (chartInstance.current) { + chartInstance.current.dispose(); + chartInstance.current = null; + } + setTimeout(() => renderChart(), 50); + } + }, [data, loading, renderChart]); + + // 窗口大小变化 + useEffect(() => { + const handleResize = () => { + chartInstance.current?.resize(); + }; + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + chartInstance.current?.dispose(); + }; + }, []); + + if (loading) { + return ( + + + + 加载热力图... + + + ); + } + + return ( + + + {showLegend && } + + ); +}; + +export default memo(MarketHeatmap); + +// 导出类型供外部使用 +export type { MarketHeatmapProps, HeatmapDataItem } from './types'; diff --git a/src/views/StockOverview/components/MarketHeatmap/styles.ts b/src/views/StockOverview/components/MarketHeatmap/styles.ts new file mode 100644 index 00000000..fe3d8a4d --- /dev/null +++ b/src/views/StockOverview/components/MarketHeatmap/styles.ts @@ -0,0 +1,52 @@ +/** + * MarketHeatmap 样式常量 + */ + +/** 颜色常量 */ +export const COLORS = { + // 主题色 + gold: '#8b5cf6', + + // 文字颜色 + text: 'rgba(255, 255, 255, 0.95)', + subText: 'rgba(255, 255, 255, 0.6)', + + // 涨跌颜色 + up: '#ff4d4d', + down: '#22c55e', + neutral: '#333333', + + // 背景 + chartBg: '#0a0a0a', + tooltipBg: '#1a1a1a', + breadcrumbBg: '#1a1a2e', +} as const; + +/** ECharts 配置常量 */ +export const CHART_CONFIG = { + /** 顶部留白(给面包屑和图例留空间) */ + topOffset: 40, + /** 面包屑高度 */ + breadcrumbHeight: 20, + /** 边框宽度 */ + borderWidth: 0.5, + /** 边框颜色 */ + borderColor: 'rgba(10, 10, 10, 0.2)', +} as const; + +/** 涨跌幅阈值 */ +export const CHANGE_THRESHOLDS = { + /** 最大涨跌幅用于颜色计算 */ + maxChange: 10, + /** 上涨基础透明度 */ + upBaseOpacity: 0.4, + /** 上涨最大额外透明度 */ + upMaxOpacity: 0.6, + /** 下跌基础透明度 */ + downBaseOpacity: 0.3, + /** 下跌最大额外透明度 */ + downMaxOpacity: 0.5, +} as const; + +/** 市值显示阈值(亿) */ +export const LABEL_DISPLAY_THRESHOLD = 5; diff --git a/src/views/StockOverview/components/MarketHeatmap/types.ts b/src/views/StockOverview/components/MarketHeatmap/types.ts new file mode 100644 index 00000000..d92cb85a --- /dev/null +++ b/src/views/StockOverview/components/MarketHeatmap/types.ts @@ -0,0 +1,49 @@ +/** + * MarketHeatmap 类型定义 + */ + +/** 热力图数据项 */ +export interface HeatmapDataItem { + stock_code: string; + stock_name: string; + change_percent: number; + market_cap: number; + amount: number; + industry?: string; + province?: string; +} + +/** MarketHeatmap 组件 Props */ +export interface MarketHeatmapProps { + /** 热力图数据 */ + data?: HeatmapDataItem[]; + /** 热力图高度,默认 400px */ + height?: string; + /** 加载状态 */ + loading?: boolean; + /** 点击股票回调 */ + onStockClick?: (stockCode: string, stockName: string) => void; + /** 是否显示图例,默认 true */ + showLegend?: boolean; +} + +/** 树图节点数据 */ +export interface TreeNodeData { + name: string; + value?: number; + change?: number; + code?: string; + amount?: number; + industry?: string; + province?: string; + itemStyle?: { color: string }; + children?: TreeNodeData[]; +} + +/** 市值区间类型 */ +export type MarketCapRange = + | '超大盘股(>1000亿)' + | '大盘股(500-1000亿)' + | '中盘股(100-500亿)' + | '小盘股(50-100亿)' + | '微盘股(<50亿)'; diff --git a/src/views/StockOverview/components/MarketHeatmap/utils.ts b/src/views/StockOverview/components/MarketHeatmap/utils.ts new file mode 100644 index 00000000..3473004e --- /dev/null +++ b/src/views/StockOverview/components/MarketHeatmap/utils.ts @@ -0,0 +1,105 @@ +/** + * MarketHeatmap 工具函数 + */ +import type { HeatmapDataItem, TreeNodeData, MarketCapRange } from './types'; +import { COLORS, CHANGE_THRESHOLDS } from './styles'; + +/** + * 获取市值区间标签 + */ +export const getMarketCapRange = (cap: number): MarketCapRange => { + if (cap >= 1000) return '超大盘股(>1000亿)'; + if (cap >= 500) return '大盘股(500-1000亿)'; + if (cap >= 100) return '中盘股(100-500亿)'; + if (cap >= 50) return '小盘股(50-100亿)'; + return '微盘股(<50亿)'; +}; + +/** + * 根据涨跌幅计算颜色 + */ +export const getChangeColor = (change: number): string => { + if (change > 0) { + const intensity = Math.min(change / CHANGE_THRESHOLDS.maxChange, 1); + const opacity = CHANGE_THRESHOLDS.upBaseOpacity + intensity * CHANGE_THRESHOLDS.upMaxOpacity; + return `rgba(255, 77, 77, ${opacity})`; + } + if (change < 0) { + const intensity = Math.min(Math.abs(change) / CHANGE_THRESHOLDS.maxChange, 1); + const opacity = CHANGE_THRESHOLDS.downBaseOpacity + intensity * CHANGE_THRESHOLDS.downMaxOpacity; + return `rgba(34, 197, 94, ${opacity})`; + } + return COLORS.neutral; +}; + +/** + * 按市值分组数据 + */ +export const groupByMarketCap = (data: HeatmapDataItem[]): Record => { + const grouped: Record = {}; + + data.forEach(item => { + const range = getMarketCapRange(item.market_cap); + if (!grouped[range]) { + grouped[range] = []; + } + grouped[range].push(item); + }); + + return grouped as Record; +}; + +/** + * 构建树图数据 + */ +export const buildTreeData = (data: HeatmapDataItem[]): TreeNodeData[] => { + const groupedData = groupByMarketCap(data); + + return Object.entries(groupedData).map(([range, stocks]) => ({ + name: range, + children: stocks.map(stock => ({ + name: stock.stock_name, + value: Math.abs(stock.market_cap), + change: stock.change_percent || 0, + code: stock.stock_code, + amount: stock.amount, + industry: stock.industry, + province: stock.province, + itemStyle: { color: getChangeColor(stock.change_percent || 0) }, + })), + })); +}; + +/** + * 生成分组 tooltip HTML + */ +export const formatGroupTooltip = (data: TreeNodeData): string => { + const totalMarketCap = data.children?.reduce((sum, item) => sum + (item.value || 0), 0) || 0; + return ` + + ${data.name} + 包含 ${data.children?.length || 0} 只股票 + 总市值: ${totalMarketCap.toFixed(2)} 亿元 + + `; +}; + +/** + * 生成股票 tooltip HTML + */ +export const formatStockTooltip = (data: TreeNodeData): string => { + const changeColor = (data.change || 0) > 0 ? COLORS.up : COLORS.down; + const changeSign = (data.change || 0) > 0 ? '+' : ''; + return ` + + ${data.name} + 代码: ${data.code || '-'} + 涨跌幅: + ${changeSign}${data.change?.toFixed(2) || 0}% + + 市值: ${data.value?.toFixed(2) || 0} 亿元 + 成交额: ${data.amount?.toFixed(2) || 0} 亿元 + 行业: ${data.industry || '未知'} + + `; +};