Files
vf_react/src/views/Concept/components/ForceGraphView.js
2025-12-05 18:30:01 +08:00

1082 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TreemapView - 概念层级矩形树图(支持钻取)
*
* 特性:
* 1. 默认显示3层一级 → 二级 → 三级)
* 2. 点击矩形钻取进入,显示该分类下的子级
* 3. 支持返回上级
* 4. 涨红跌绿颜色映射
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react';
import {
Box,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Flex,
Button,
IconButton,
Tooltip,
Badge,
useBreakpointValue,
} from '@chakra-ui/react';
import {
FaLayerGroup,
FaSync,
FaExpand,
FaCompress,
FaHome,
FaArrowUp,
FaArrowDown,
FaCircle,
FaTh,
FaChevronRight,
FaArrowLeft,
} from 'react-icons/fa';
import { logger } from '../../../utils/logger';
// 一级分类颜色映射(基础色)
const LV1_COLORS = {
'人工智能': '#8B5CF6',
'半导体': '#3B82F6',
'机器人': '#10B981',
'消费电子': '#F59E0B',
'智能驾驶与汽车': '#EF4444',
'新能源与电力': '#06B6D4',
'空天经济': '#6366F1',
'国防军工': '#EC4899',
'政策与主题': '#14B8A6',
'周期与材料': '#F97316',
'大消费': '#A855F7',
'数字经济与金融科技': '#22D3EE',
'全球宏观与贸易': '#84CC16',
'医药健康': '#E879F9',
'前沿科技': '#38BDF8',
};
// 根据涨跌幅获取颜色(涨红跌绿)
const getChangeColor = (value, baseColor = '#64748B') => {
if (value === null || value === undefined) return baseColor;
// 涨 - 红色系
if (value > 7) return '#DC2626';
if (value > 5) return '#EF4444';
if (value > 3) return '#F87171';
if (value > 1) return '#FCA5A5';
if (value > 0) return '#FECACA';
// 跌 - 绿色系
if (value < -7) return '#15803D';
if (value < -5) return '#16A34A';
if (value < -3) return '#22C55E';
if (value < -1) return '#4ADE80';
if (value < 0) return '#86EFAC';
return baseColor;
};
// 从 API 返回的名称中提取纯名称
const extractPureName = (apiName) => {
if (!apiName) return '';
return apiName.replace(/^\[(一级|二级|三级)\]\s*/, '');
};
// 格式化涨跌幅
const formatChangePercent = (value) => {
if (value === null || value === undefined) return '--';
const formatted = Math.abs(value).toFixed(2);
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
};
/**
* 主组件
*/
const ForceGraphView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} });
const [priceLoading, setPriceLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// 钻取状态:记录当前查看的路径
const [drillPath, setDrillPath] = useState(null);
const chartRef = useRef();
const containerRef = useRef();
const isMobile = useBreakpointValue({ base: true, md: false });
// 获取层级结构数据
const fetchHierarchy = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/hierarchy`);
if (!response.ok) throw new Error('获取层级结构失败');
const data = await response.json();
setHierarchy(data.hierarchy || []);
logger.info('TreemapView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.error('TreemapView', 'fetchHierarchy', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
// 获取层级涨跌幅数据
const fetchHierarchyPrice = useCallback(async () => {
setPriceLoading(true);
try {
let url = `${apiBaseUrl}/hierarchy/price`;
if (selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
url += `?trade_date=${dateStr}`;
}
const response = await fetch(url);
if (!response.ok) {
logger.warn('TreemapView', '获取层级涨跌幅失败', { status: response.status });
return;
}
const data = await response.json();
const lv1Map = {};
const lv2Map = {};
const lv3Map = {};
const leafMap = {};
(data.lv1_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv1Map[pureName] = item;
});
(data.lv2_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv2Map[pureName] = item;
});
(data.lv3_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv3Map[pureName] = item;
});
(data.leaf_concepts || []).forEach(item => {
leafMap[item.concept_name] = item;
});
setPriceData({ lv1Map, lv2Map, lv3Map, leafMap });
} catch (err) {
logger.warn('TreemapView', '获取层级涨跌幅失败', { error: err.message });
} finally {
setPriceLoading(false);
}
}, [apiBaseUrl, selectedDate]);
useEffect(() => {
fetchHierarchy();
}, [fetchHierarchy]);
useEffect(() => {
if (hierarchy.length > 0) {
fetchHierarchyPrice();
}
}, [hierarchy, fetchHierarchyPrice]);
// 根据钻取路径构建 Treemap 数据
const treemapData = useMemo(() => {
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
// 根视图:显示所有一级,每个一级下显示二级和三级(不显示概念)
if (!drillPath) {
return hierarchy.map((lv1) => {
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
const lv1Price = lv1Map[lv1.name] || {};
const lv1Node = {
name: lv1.name,
value: lv1Price.stock_count || lv1.concept_count * 10 || 100,
itemStyle: {
color: getChangeColor(lv1Price.avg_change_pct, lv1BaseColor),
borderColor: '#1E293B',
borderWidth: 2,
},
data: {
level: 'lv1',
changePct: lv1Price.avg_change_pct,
stockCount: lv1Price.stock_count,
conceptCount: lv1.concept_count,
baseColor: lv1BaseColor,
},
children: [],
};
// 二级
if (lv1.children) {
lv1.children.forEach((lv2) => {
const lv2Price = lv2Map[lv2.name] || {};
const lv2Node = {
name: lv2.name,
value: lv2Price.stock_count || lv2.concept_count * 5 || 50,
itemStyle: {
color: getChangeColor(lv2Price.avg_change_pct, lv1BaseColor),
borderColor: '#334155',
borderWidth: 1,
},
data: {
level: 'lv2',
parentLv1: lv1.name,
changePct: lv2Price.avg_change_pct,
stockCount: lv2Price.stock_count,
conceptCount: lv2.concept_count,
baseColor: lv1BaseColor,
},
children: [],
};
// 三级(不显示概念)
if (lv2.children) {
lv2.children.forEach((lv3) => {
const lv3Price = lv3Map[lv3.name] || {};
lv2Node.children.push({
name: lv3.name,
value: lv3Price.stock_count || 30,
itemStyle: {
color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor),
borderColor: '#475569',
borderWidth: 1,
},
data: {
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
conceptCount: lv3.concepts?.length || 0,
baseColor: lv1BaseColor,
hasChildren: lv3.concepts && lv3.concepts.length > 0,
},
});
});
}
lv1Node.children.push(lv2Node);
});
}
return lv1Node;
});
}
// 钻取到某个一级分类
if (drillPath.lv1 && !drillPath.lv2) {
const lv1 = hierarchy.find(h => h.name === drillPath.lv1);
if (!lv1) return [];
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
return (lv1.children || []).map((lv2) => {
const lv2Price = lv2Map[lv2.name] || {};
const lv2Node = {
name: lv2.name,
value: lv2Price.stock_count || lv2.concept_count * 5 || 50,
itemStyle: {
color: getChangeColor(lv2Price.avg_change_pct, lv1BaseColor),
borderColor: '#1E293B',
borderWidth: 2,
},
data: {
level: 'lv2',
parentLv1: lv1.name,
changePct: lv2Price.avg_change_pct,
stockCount: lv2Price.stock_count,
conceptCount: lv2.concept_count,
baseColor: lv1BaseColor,
},
children: [],
};
// 三级
if (lv2.children) {
lv2.children.forEach((lv3) => {
const lv3Price = lv3Map[lv3.name] || {};
const lv3Node = {
name: lv3.name,
value: lv3Price.stock_count || 30,
itemStyle: {
color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor),
borderColor: '#334155',
borderWidth: 1,
},
data: {
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
baseColor: lv1BaseColor,
},
children: [],
};
// 概念
if (lv3.concepts) {
lv3.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
lv3Node.children.push({
name: conceptName,
value: conceptPrice.stock_count || 10,
itemStyle: {
color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
borderColor: '#475569',
borderWidth: 1,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
},
});
});
}
lv2Node.children.push(lv3Node);
});
}
// lv2 直接包含的概念
if (lv2.concepts) {
lv2.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
lv2Node.children.push({
name: conceptName,
value: conceptPrice.stock_count || 10,
itemStyle: {
color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
borderColor: '#475569',
borderWidth: 1,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
},
});
});
}
return lv2Node;
});
}
// 钻取到某个二级分类
if (drillPath.lv1 && drillPath.lv2 && !drillPath.lv3) {
const lv1 = hierarchy.find(h => h.name === drillPath.lv1);
if (!lv1) return [];
const lv2 = lv1.children?.find(c => c.name === drillPath.lv2);
if (!lv2) return [];
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
const result = [];
// 三级
if (lv2.children) {
lv2.children.forEach((lv3) => {
const lv3Price = lv3Map[lv3.name] || {};
const lv3Node = {
name: lv3.name,
value: lv3Price.stock_count || 30,
itemStyle: {
color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor),
borderColor: '#1E293B',
borderWidth: 2,
},
data: {
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
baseColor: lv1BaseColor,
},
children: [],
};
// 概念
if (lv3.concepts) {
lv3.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
lv3Node.children.push({
name: conceptName,
value: conceptPrice.stock_count || 10,
itemStyle: {
color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
borderColor: '#334155',
borderWidth: 1,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
},
});
});
}
result.push(lv3Node);
});
}
// lv2 直接包含的概念
if (lv2.concepts) {
lv2.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
result.push({
name: conceptName,
value: conceptPrice.stock_count || 10,
itemStyle: {
color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
borderColor: '#1E293B',
borderWidth: 2,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
},
});
});
}
return result;
}
// 钻取到某个三级分类
if (drillPath.lv1 && drillPath.lv2 && drillPath.lv3) {
const lv1 = hierarchy.find(h => h.name === drillPath.lv1);
if (!lv1) return [];
const lv2 = lv1.children?.find(c => c.name === drillPath.lv2);
if (!lv2) return [];
const lv3 = lv2.children?.find(c => c.name === drillPath.lv3);
if (!lv3) return [];
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
return (lv3.concepts || []).map((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
return {
name: conceptName,
value: conceptPrice.stock_count || 10,
itemStyle: {
color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
borderColor: '#1E293B',
borderWidth: 2,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
},
};
});
}
return [];
}, [hierarchy, priceData, drillPath]);
// 获取当前显示的层级数
const currentLevels = useMemo(() => {
if (!drillPath) return 3;
if (drillPath.lv1 && !drillPath.lv2) return 3;
if (drillPath.lv1 && drillPath.lv2 && !drillPath.lv3) return 2;
if (drillPath.lv1 && drillPath.lv2 && drillPath.lv3) return 1;
return 3;
}, [drillPath]);
// ECharts 配置
const chartOption = useMemo(() => {
// 根据层级深度设置不同的 levels 配置
const levels = [
{
// 根节点配置
itemStyle: {
borderColor: '#0F172A',
borderWidth: 0,
gapWidth: 1,
},
},
{
// 第一层
itemStyle: {
borderColor: '#1E293B',
borderWidth: 3,
gapWidth: 2,
},
upperLabel: {
show: true,
height: 30,
color: '#FFFFFF',
fontSize: 14,
fontWeight: 'bold',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowBlur: 4,
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
const changeStr = changePct !== undefined && changePct !== null
? ` ${formatChangePercent(changePct)}`
: '';
return `${params.name}${changeStr}`;
},
},
},
{
// 第二层
itemStyle: {
borderColor: '#334155',
borderWidth: 2,
gapWidth: 1,
},
upperLabel: {
show: true,
height: 24,
color: '#E2E8F0',
fontSize: 12,
fontWeight: 'bold',
textShadowColor: 'rgba(0,0,0,0.6)',
textShadowBlur: 3,
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
const changeStr = changePct !== undefined && changePct !== null
? ` ${formatChangePercent(changePct)}`
: '';
return `${params.name}${changeStr}`;
},
},
},
{
// 第三层
itemStyle: {
borderColor: '#475569',
borderWidth: 1,
gapWidth: 1,
},
label: {
show: true,
position: 'insideTopLeft',
color: '#F1F5F9',
fontSize: 11,
textShadowColor: 'rgba(0,0,0,0.5)',
textShadowBlur: 2,
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
if (changePct !== undefined && changePct !== null) {
return `${params.name}\n${formatChangePercent(changePct)}`;
}
return params.name;
},
},
},
{
// 第四层(概念层,只在钻取时显示)
itemStyle: {
borderColor: '#64748B',
borderWidth: 1,
gapWidth: 1,
},
label: {
show: true,
position: 'insideTopLeft',
color: '#F8FAFC',
fontSize: 10,
textShadowColor: 'rgba(0,0,0,0.4)',
textShadowBlur: 2,
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
if (changePct !== undefined && changePct !== null) {
return `${params.name}\n${formatChangePercent(changePct)}`;
}
return params.name;
},
},
},
];
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.95)',
borderColor: 'rgba(139, 92, 246, 0.5)',
borderWidth: 1,
borderRadius: 12,
padding: [12, 16],
textStyle: {
color: '#E2E8F0',
fontSize: 13,
},
formatter: (params) => {
const data = params.data?.data || {};
const levelMap = {
'lv1': '一级分类',
'lv2': '二级分类',
'lv3': '三级分类',
'concept': '概念',
};
const changePct = data.changePct;
const changeColor = changePct > 0 ? '#F87171' : changePct < 0 ? '#4ADE80' : '#94A3B8';
const changeIcon = changePct > 0 ? '▲' : changePct < 0 ? '▼' : '●';
return `
<div style="min-width: 180px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="
background: ${data.baseColor || '#8B5CF6'};
color: white;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
">${levelMap[data.level] || '分类'}</span>
</div>
<div style="font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #FFFFFF;">
${params.name}
</div>
${changePct !== undefined && changePct !== null ? `
<div style="
display: inline-flex;
align-items: center;
gap: 6px;
background: ${changePct > 0 ? 'rgba(248, 113, 113, 0.2)' : changePct < 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(148, 163, 184, 0.2)'};
padding: 6px 12px;
border-radius: 8px;
margin-bottom: 10px;
">
<span style="color: ${changeColor}; font-size: 14px;">${changeIcon}</span>
<span style="color: ${changeColor}; font-weight: bold; font-size: 18px; font-family: monospace;">
${formatChangePercent(changePct)}
</span>
</div>
` : ''}
<div style="color: #94A3B8; font-size: 12px;">
${data.stockCount ? `${data.stockCount} 只股票` : ''}
${data.conceptCount ? ` · ${data.conceptCount} 个概念` : ''}
</div>
${data.level === 'concept' ? `
<div style="color: #A78BFA; font-size: 11px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);">
🔗 点击查看概念详情
</div>
` : data.level !== 'concept' ? `
<div style="color: #64748B; font-size: 11px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);">
📂 点击进入查看子分类
</div>
` : ''}
</div>
`;
},
},
series: [
{
type: 'treemap',
data: treemapData,
left: 0,
top: 60,
right: 0,
bottom: 50,
roam: false,
nodeClick: false, // 禁用内置的点击行为,我们自己处理
breadcrumb: {
show: false, // 隐藏内置面包屑,使用自定义的
},
levels: levels,
label: {
show: true,
formatter: '{b}',
},
itemStyle: {
borderRadius: 4,
},
emphasis: {
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(139, 92, 246, 0.6)',
},
},
animation: true,
animationDuration: 500,
animationEasing: 'cubicOut',
},
],
};
}, [treemapData, currentLevels]);
// 图表事件
const onChartEvents = useMemo(() => ({
click: (params) => {
const data = params.data?.data || {};
if (data.level === 'concept') {
// 跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(params.name)}.html`;
window.open(htmlPath, '_blank');
return;
}
// 钻取进入
if (data.level === 'lv1') {
setDrillPath({ lv1: params.name });
} else if (data.level === 'lv2') {
setDrillPath({ lv1: data.parentLv1, lv2: params.name });
} else if (data.level === 'lv3') {
setDrillPath({ lv1: data.parentLv1, lv2: data.parentLv2, lv3: params.name });
}
logger.info('TreemapView', '钻取', { level: data.level, name: params.name });
},
}), []);
// 返回上一层
const handleGoBack = useCallback(() => {
if (!drillPath) return;
if (drillPath.lv3) {
setDrillPath({ lv1: drillPath.lv1, lv2: drillPath.lv2 });
} else if (drillPath.lv2) {
setDrillPath({ lv1: drillPath.lv1 });
} else if (drillPath.lv1) {
setDrillPath(null);
}
}, [drillPath]);
// 返回根视图
const handleGoHome = useCallback(() => {
setDrillPath(null);
}, []);
// 刷新数据
const handleRefresh = useCallback(() => {
fetchHierarchyPrice();
}, [fetchHierarchyPrice]);
// 全屏切换
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// 获取面包屑
const breadcrumbItems = useMemo(() => {
const items = [{ label: '全部分类', path: null }];
if (drillPath?.lv1) {
items.push({ label: drillPath.lv1, path: { lv1: drillPath.lv1 } });
}
if (drillPath?.lv2) {
items.push({ label: drillPath.lv2, path: { lv1: drillPath.lv1, lv2: drillPath.lv2 } });
}
if (drillPath?.lv3) {
items.push({ label: drillPath.lv3, path: { lv1: drillPath.lv1, lv2: drillPath.lv2, lv3: drillPath.lv3 } });
}
return items;
}, [drillPath]);
// 获取容器高度
const containerHeight = useMemo(() => {
if (isFullscreen) return '100vh';
return isMobile ? '500px' : '700px';
}, [isFullscreen, isMobile]);
if (loading) {
return (
<Center h="500px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
<VStack spacing={4}>
<Spinner size="xl" color="purple.400" thickness="4px" />
<Text color="gray.400">正在构建矩形树图...</Text>
</VStack>
</Center>
);
}
if (error) {
return (
<Center h="500px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
<VStack spacing={4}>
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
<Text color="gray.400">加载失败{error}</Text>
<Button colorScheme="purple" size="sm" onClick={fetchHierarchy}>
重试
</Button>
</VStack>
</Center>
);
}
return (
<Box
ref={containerRef}
position={isFullscreen ? 'fixed' : 'relative'}
top={isFullscreen ? 0 : 'auto'}
left={isFullscreen ? 0 : 'auto'}
right={isFullscreen ? 0 : 'auto'}
bottom={isFullscreen ? 0 : 'auto'}
zIndex={isFullscreen ? 1000 : 'auto'}
bg="linear-gradient(135deg, #0F172A 0%, #1E1B4B 50%, #0F172A 100%)"
borderRadius={isFullscreen ? '0' : '2xl'}
overflow="hidden"
border={isFullscreen ? 'none' : '1px solid'}
borderColor="whiteAlpha.200"
h={containerHeight}
>
{/* 顶部工具栏 */}
<Flex
position="absolute"
top={4}
left={4}
right={4}
justify="space-between"
align="flex-start"
zIndex={10}
pointerEvents="none"
>
{/* 左侧标题和面包屑 */}
<VStack align="start" spacing={2} pointerEvents="auto">
<HStack spacing={3}>
{/* 返回按钮 */}
{drillPath && (
<Tooltip label="返回上一层" placement="bottom">
<IconButton
size="sm"
icon={<FaArrowLeft />}
onClick={handleGoBack}
bg="rgba(0, 0, 0, 0.7)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'purple.500', borderColor: 'purple.400' }}
aria-label="返回"
/>
</Tooltip>
)}
<HStack
bg="rgba(0, 0, 0, 0.7)"
backdropFilter="blur(12px)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
>
<Icon as={FaTh} color="purple.300" />
<Text color="white" fontWeight="bold" fontSize="sm">
概念矩形树图
</Text>
</HStack>
{/* 面包屑导航 */}
<HStack
bg="rgba(0, 0, 0, 0.7)"
backdropFilter="blur(12px)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={2}
>
{breadcrumbItems.map((item, index) => (
<HStack key={index} spacing={2}>
{index > 0 && (
<Icon as={FaChevronRight} color="whiteAlpha.400" boxSize={3} />
)}
<Text
color={index === breadcrumbItems.length - 1 ? 'purple.300' : 'whiteAlpha.700'}
fontSize="sm"
fontWeight={index === breadcrumbItems.length - 1 ? 'bold' : 'normal'}
cursor={index < breadcrumbItems.length - 1 ? 'pointer' : 'default'}
_hover={index < breadcrumbItems.length - 1 ? { color: 'white' } : {}}
onClick={() => {
if (index < breadcrumbItems.length - 1) {
setDrillPath(item.path);
}
}}
>
{item.label}
</Text>
</HStack>
))}
</HStack>
</HStack>
</VStack>
{/* 右侧控制按钮 */}
<HStack spacing={2} pointerEvents="auto">
{priceLoading && <Spinner size="sm" color="purple.300" />}
{drillPath && (
<Tooltip label="返回全部" placement="left">
<IconButton
size="sm"
icon={<FaHome />}
onClick={handleGoHome}
bg="rgba(0, 0, 0, 0.7)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'purple.500', borderColor: 'purple.400' }}
aria-label="返回全部"
/>
</Tooltip>
)}
<Tooltip label="刷新数据" placement="left">
<IconButton
size="sm"
icon={<FaSync />}
onClick={handleRefresh}
isLoading={priceLoading}
bg="rgba(0, 0, 0, 0.7)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200', borderColor: 'purple.400' }}
aria-label="刷新"
/>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left">
<IconButton
size="sm"
icon={isFullscreen ? <FaCompress /> : <FaExpand />}
onClick={toggleFullscreen}
bg="rgba(0, 0, 0, 0.7)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200', borderColor: 'purple.400' }}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
</Tooltip>
</HStack>
</Flex>
{/* 底部图例 */}
<Flex
position="absolute"
bottom={4}
left={4}
zIndex={10}
gap={2}
flexWrap="wrap"
pointerEvents="none"
>
<HStack
bg="rgba(0, 0, 0, 0.7)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={2}
>
<Box w={3} h={3} borderRadius="sm" bg="#EF4444" />
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
<HStack
bg="rgba(0, 0, 0, 0.7)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={2}
>
<Box w={3} h={3} borderRadius="sm" bg="#22C55E" />
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
</Flex>
{/* 操作提示 */}
<Box
position="absolute"
bottom={4}
right={4}
zIndex={10}
pointerEvents="none"
>
<HStack
bg="rgba(0, 0, 0, 0.7)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
>
<Text color="whiteAlpha.600" fontSize="xs">
点击分类进入 · 点击概念查看详情
</Text>
</HStack>
</Box>
{/* ECharts 图表 */}
<ReactECharts
ref={chartRef}
option={chartOption}
style={{ height: '100%', width: '100%' }}
onEvents={onChartEvents}
opts={{ renderer: 'canvas' }}
/>
</Box>
);
};
export default ForceGraphView;