refactor(MarketHeatmap): TypeScript 重构与模块化优化

目录结构拆分:
- types.ts: HeatmapDataItem, MarketHeatmapProps, TreeNodeData 等类型定义
- styles.ts: 颜色常量、ECharts 配置常量、涨跌幅阈值
- utils.ts: getMarketCapRange, getChangeColor, buildTreeData, tooltip 格式化函数
- components/HeatmapLegend.tsx: 图例原子组件

性能优化:
- 使用 useMemo 缓存树图数据构建和 ECharts 配置
- HeatmapLegend 使用 memo 包装

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-31 17:52:39 +08:00
parent f4c194881f
commit 0eb1d00482
6 changed files with 449 additions and 0 deletions

View File

@@ -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 (
<HStack
spacing={4}
align="center"
position="absolute"
top="5px"
right="8px"
zIndex={10}
>
{LEGEND_ITEMS.map(({ color, label, glow }) => (
<HStack key={label} spacing={2}>
<Box
w={3}
h={3}
bg={color}
borderRadius="sm"
boxShadow={glow ? `0 0 8px ${color}50` : undefined}
/>
<Text fontSize="xs" color={COLORS.text}>
{label}
</Text>
</HStack>
))}
</HStack>
);
});
HeatmapLegend.displayName = 'HeatmapLegend';
export default HeatmapLegend;

View File

@@ -0,0 +1 @@
export { default as HeatmapLegend } from './HeatmapLegend';

View File

@@ -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<MarketHeatmapProps> = ({
data = [],
height = '400px',
loading = false,
onStockClick,
showLegend = true,
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(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 (
<Center h={height}>
<VStack spacing={4}>
<Spinner size="lg" color={COLORS.gold} thickness="3px" />
<Text color={COLORS.subText} fontSize="sm">...</Text>
</VStack>
</Center>
);
}
return (
<Box position="relative">
<Box ref={chartRef} h={height} w="100%" />
{showLegend && <HeatmapLegend />}
</Box>
);
};
export default memo(MarketHeatmap);
// 导出类型供外部使用
export type { MarketHeatmapProps, HeatmapDataItem } from './types';

View File

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

View File

@@ -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亿)';

View File

@@ -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<MarketCapRange, HeatmapDataItem[]> => {
const grouped: Record<string, HeatmapDataItem[]> = {};
data.forEach(item => {
const range = getMarketCapRange(item.market_cap);
if (!grouped[range]) {
grouped[range] = [];
}
grouped[range].push(item);
});
return grouped as Record<MarketCapRange, HeatmapDataItem[]>;
};
/**
* 构建树图数据
*/
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 `
<div style="padding: 10px; color: white;">
<div style="font-weight: bold; margin-bottom: 6px; font-size: 14px; color: ${COLORS.gold};">${data.name}</div>
<div style="color: #ccc;">包含 ${data.children?.length || 0} 只股票</div>
<div style="color: #ccc;">总市值: <span style="color: ${COLORS.gold}; font-weight: bold;">${totalMarketCap.toFixed(2)}</span> 亿元</div>
</div>
`;
};
/**
* 生成股票 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 `
<div style="padding: 10px; color: white;">
<div style="font-weight: bold; margin-bottom: 6px; font-size: 14px; color: ${COLORS.gold};">${data.name}</div>
<div style="color: #ccc;">代码: ${data.code || '-'}</div>
<div style="color: #ccc;">涨跌幅: <span style="color: ${changeColor}; font-weight: bold;">
${changeSign}${data.change?.toFixed(2) || 0}%
</span></div>
<div style="color: #ccc;">市值: <span style="font-weight: bold;">${data.value?.toFixed(2) || 0}</span> 亿元</div>
<div style="color: #ccc;">成交额: <span style="font-weight: bold;">${data.amount?.toFixed(2) || 0}</span> 亿元</div>
<div style="color: #ccc;">行业: ${data.industry || '未知'}</div>
</div>
`;
};