/** * 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 `