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:
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HeatmapLegend } from './HeatmapLegend';
|
||||
192
src/views/StockOverview/components/MarketHeatmap/index.tsx
Normal file
192
src/views/StockOverview/components/MarketHeatmap/index.tsx
Normal 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';
|
||||
52
src/views/StockOverview/components/MarketHeatmap/styles.ts
Normal file
52
src/views/StockOverview/components/MarketHeatmap/styles.ts
Normal 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;
|
||||
49
src/views/StockOverview/components/MarketHeatmap/types.ts
Normal file
49
src/views/StockOverview/components/MarketHeatmap/types.ts
Normal 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亿)';
|
||||
105
src/views/StockOverview/components/MarketHeatmap/utils.ts
Normal file
105
src/views/StockOverview/components/MarketHeatmap/utils.ts
Normal 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>
|
||||
`;
|
||||
};
|
||||
Reference in New Issue
Block a user