diff --git a/src/views/Concept/components/ForceGraphView.js b/src/views/Concept/components/ForceGraphView.js index 45a9a372..b1f0de73 100644 --- a/src/views/Concept/components/ForceGraphView.js +++ b/src/views/Concept/components/ForceGraphView.js @@ -1,9 +1,9 @@ /** - * SunburstView - 概念层级旭日图(支持钻取) + * TreemapView - 概念层级矩形树图(支持钻取) * * 特性: * 1. 默认显示3层(一级 → 二级 → 三级) - * 2. 点击扇区钻取进入,显示该分类下的子级 + * 2. 点击矩形钻取进入,显示该分类下的子级 * 3. 支持返回上级 * 4. 涨红跌绿颜色映射 */ @@ -23,9 +23,6 @@ import { Tooltip, Badge, useBreakpointValue, - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, } from '@chakra-ui/react'; import { FaLayerGroup, @@ -36,12 +33,10 @@ import { FaArrowUp, FaArrowDown, FaCircle, - FaChartPie, - FaUndo, + FaTh, FaChevronRight, FaArrowLeft, } from 'react-icons/fa'; -import { ChevronRightIcon } from '@chakra-ui/icons'; import { logger } from '../../../utils/logger'; // 一级分类颜色映射(基础色) @@ -72,14 +67,14 @@ const getChangeColor = (value, baseColor = '#64748B') => { if (value > 5) return '#EF4444'; if (value > 3) return '#F87171'; if (value > 1) return '#FCA5A5'; - if (value > 0) return '#FED7D7'; + 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 '#BBF7D0'; + if (value < 0) return '#86EFAC'; return baseColor; }; @@ -111,13 +106,8 @@ const ForceGraphView = ({ const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} }); const [priceLoading, setPriceLoading] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - const [hoveredItem, setHoveredItem] = useState(null); // 钻取状态:记录当前查看的路径 - // null = 根视图(显示所有一级) - // { lv1: '人工智能' } = 查看人工智能下的二级和三级 - // { lv1: '人工智能', lv2: 'AI基础设施' } = 查看AI基础设施下的三级和概念 - // { lv1: '人工智能', lv2: 'AI基础设施', lv3: 'AI芯片' } = 查看AI芯片下的概念 const [drillPath, setDrillPath] = useState(null); const chartRef = useRef(); @@ -137,12 +127,12 @@ const ForceGraphView = ({ const data = await response.json(); setHierarchy(data.hierarchy || []); - logger.info('SunburstView', '层级结构加载完成', { + logger.info('TreemapView', '层级结构加载完成', { totalLv1: data.hierarchy?.length, totalConcepts: data.total_concepts }); } catch (err) { - logger.error('SunburstView', 'fetchHierarchy', err); + logger.error('TreemapView', 'fetchHierarchy', err); setError(err.message); } finally { setLoading(false); @@ -162,7 +152,7 @@ const ForceGraphView = ({ const response = await fetch(url); if (!response.ok) { - logger.warn('SunburstView', '获取层级涨跌幅失败', { status: response.status }); + logger.warn('TreemapView', '获取层级涨跌幅失败', { status: response.status }); return; } @@ -191,7 +181,7 @@ const ForceGraphView = ({ setPriceData({ lv1Map, lv2Map, lv3Map, leafMap }); } catch (err) { - logger.warn('SunburstView', '获取层级涨跌幅失败', { error: err.message }); + logger.warn('TreemapView', '获取层级涨跌幅失败', { error: err.message }); } finally { setPriceLoading(false); } @@ -207,8 +197,8 @@ const ForceGraphView = ({ } }, [hierarchy, fetchHierarchyPrice]); - // 根据钻取路径构建旭日图数据 - const sunburstData = useMemo(() => { + // 根据钻取路径构建 Treemap 数据 + const treemapData = useMemo(() => { const { lv1Map, lv2Map, lv3Map, leafMap } = priceData; // 根视图:显示所有一级,每个一级下显示二级和三级(不显示概念) @@ -222,6 +212,8 @@ const ForceGraphView = ({ value: lv1Price.stock_count || lv1.concept_count * 10 || 100, itemStyle: { color: getChangeColor(lv1Price.avg_change_pct, lv1BaseColor), + borderColor: '#1E293B', + borderWidth: 2, }, data: { level: 'lv1', @@ -243,6 +235,8 @@ const ForceGraphView = ({ value: lv2Price.stock_count || lv2.concept_count * 5 || 50, itemStyle: { color: getChangeColor(lv2Price.avg_change_pct, lv1BaseColor), + borderColor: '#334155', + borderWidth: 1, }, data: { level: 'lv2', @@ -265,6 +259,8 @@ const ForceGraphView = ({ value: lv3Price.stock_count || 30, itemStyle: { color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor), + borderColor: '#475569', + borderWidth: 1, }, data: { level: 'lv3', @@ -288,7 +284,7 @@ const ForceGraphView = ({ }); } - // 钻取到某个一级分类:显示该一级下的二级、三级、概念 + // 钻取到某个一级分类 if (drillPath.lv1 && !drillPath.lv2) { const lv1 = hierarchy.find(h => h.name === drillPath.lv1); if (!lv1) return []; @@ -303,6 +299,8 @@ const ForceGraphView = ({ value: lv2Price.stock_count || lv2.concept_count * 5 || 50, itemStyle: { color: getChangeColor(lv2Price.avg_change_pct, lv1BaseColor), + borderColor: '#1E293B', + borderWidth: 2, }, data: { level: 'lv2', @@ -325,6 +323,8 @@ const ForceGraphView = ({ value: lv3Price.stock_count || 30, itemStyle: { color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor), + borderColor: '#334155', + borderWidth: 1, }, data: { level: 'lv3', @@ -346,6 +346,8 @@ const ForceGraphView = ({ value: conceptPrice.stock_count || 10, itemStyle: { color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor), + borderColor: '#475569', + borderWidth: 1, }, data: { level: 'concept', @@ -373,6 +375,8 @@ const ForceGraphView = ({ value: conceptPrice.stock_count || 10, itemStyle: { color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor), + borderColor: '#475569', + borderWidth: 1, }, data: { level: 'concept', @@ -390,7 +394,7 @@ const ForceGraphView = ({ }); } - // 钻取到某个二级分类:显示该二级下的三级和概念 + // 钻取到某个二级分类 if (drillPath.lv1 && drillPath.lv2 && !drillPath.lv3) { const lv1 = hierarchy.find(h => h.name === drillPath.lv1); if (!lv1) return []; @@ -412,6 +416,8 @@ const ForceGraphView = ({ value: lv3Price.stock_count || 30, itemStyle: { color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor), + borderColor: '#1E293B', + borderWidth: 2, }, data: { level: 'lv3', @@ -433,6 +439,8 @@ const ForceGraphView = ({ value: conceptPrice.stock_count || 10, itemStyle: { color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor), + borderColor: '#334155', + borderWidth: 1, }, data: { level: 'concept', @@ -460,6 +468,8 @@ const ForceGraphView = ({ value: conceptPrice.stock_count || 10, itemStyle: { color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor), + borderColor: '#1E293B', + borderWidth: 2, }, data: { level: 'concept', @@ -476,7 +486,7 @@ const ForceGraphView = ({ return result; } - // 钻取到某个三级分类:显示该三级下的概念 + // 钻取到某个三级分类 if (drillPath.lv1 && drillPath.lv2 && drillPath.lv3) { const lv1 = hierarchy.find(h => h.name === drillPath.lv1); if (!lv1) return []; @@ -496,6 +506,8 @@ const ForceGraphView = ({ value: conceptPrice.stock_count || 10, itemStyle: { color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor), + borderColor: '#1E293B', + borderWidth: 2, }, data: { level: 'concept', @@ -515,54 +527,124 @@ const ForceGraphView = ({ // 获取当前显示的层级数 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; // 三级钻取:概念 + 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(() => { - // 根据当前层级数调整半径 - const levelConfigs = { - 1: [ - {}, - { r0: '15%', r: '90%', label: { show: true, rotate: 'tangential', fontSize: 12, fontWeight: 'bold' } }, - ], - 2: [ - {}, - { r0: '15%', r: '50%', label: { show: true, rotate: 'tangential', fontSize: 12, fontWeight: 'bold' } }, - { r0: '50%', r: '90%', label: { show: true, rotate: 'radial', fontSize: 10 } }, - ], - 3: [ - {}, - { r0: '15%', r: '40%', label: { show: true, rotate: 'tangential', fontSize: 12, fontWeight: 'bold' } }, - { r0: '40%', r: '65%', label: { show: true, rotate: 'tangential', fontSize: 10 } }, - { r0: '65%', r: '90%', label: { show: true, rotate: 'radial', fontSize: 9 } }, - ], - }; - - const levels = levelConfigs[currentLevels] || levelConfigs[3]; - - // 为每个层级添加样式 - const styledLevels = levels.map((level, index) => { - if (index === 0) return level; - return { - ...level, + // 根据层级深度设置不同的 levels 配置 + const levels = [ + { + // 根节点配置 itemStyle: { - borderWidth: index === 1 ? 3 : index === 2 ? 2 : 1, borderColor: '#0F172A', - borderRadius: 4, + 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: { - ...level.label, - color: '#FFFFFF', - textShadowColor: '#000', - textShadowBlur: 4, + 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', @@ -591,17 +673,17 @@ const ForceGraphView = ({ const changeIcon = changePct > 0 ? '▲' : changePct < 0 ? '▼' : '●'; return ` -
+
${levelMap[data.level] || '分类'}
-
+
${params.name}
${changePct !== undefined && changePct !== null ? ` @@ -610,12 +692,12 @@ const ForceGraphView = ({ 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: 4px 10px; + padding: 6px 12px; border-radius: 8px; - margin-bottom: 8px; + margin-bottom: 10px; "> - ${changeIcon} - + ${changeIcon} + ${formatChangePercent(changePct)}
@@ -625,43 +707,52 @@ const ForceGraphView = ({ ${data.conceptCount ? ` · ${data.conceptCount} 个概念` : ''}
${data.level === 'concept' ? ` -
- 点击查看详情 → +
+ 🔗 点击查看概念详情
- ` : ` -
- 点击进入查看详情 + ` : data.level !== 'concept' ? ` +
+ 📂 点击进入查看子分类
- `} + ` : ''}
`; }, }, series: [ { - type: 'sunburst', - data: sunburstData, - radius: ['15%', '90%'], - center: ['50%', '50%'], - sort: 'desc', - emphasis: { - focus: 'ancestor', - itemStyle: { - shadowBlur: 20, - shadowColor: 'rgba(139, 92, 246, 0.5)', - }, + 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}', }, - levels: styledLevels, itemStyle: { borderRadius: 4, }, + emphasis: { + itemStyle: { + shadowBlur: 20, + shadowColor: 'rgba(139, 92, 246, 0.6)', + }, + }, animation: true, - animationDuration: 600, + animationDuration: 500, animationEasing: 'cubicOut', }, ], }; - }, [sunburstData, currentLevels]); + }, [treemapData, currentLevels]); // 图表事件 const onChartEvents = useMemo(() => ({ @@ -684,18 +775,9 @@ const ForceGraphView = ({ setDrillPath({ lv1: data.parentLv1, lv2: data.parentLv2, lv3: params.name }); } - logger.info('SunburstView', '钻取', { level: data.level, name: params.name, path: drillPath }); + logger.info('TreemapView', '钻取', { level: data.level, name: params.name }); }, - mouseover: (params) => { - setHoveredItem({ - name: params.name, - data: params.data?.data, - }); - }, - mouseout: () => { - setHoveredItem(null); - }, - }), [drillPath]); + }), []); // 返回上一层 const handleGoBack = useCallback(() => { @@ -753,7 +835,7 @@ const ForceGraphView = ({
- 正在构建旭日图... + 正在构建矩形树图...
); @@ -789,18 +871,6 @@ const ForceGraphView = ({ borderColor="whiteAlpha.200" h={containerHeight} > - {/* 背景装饰 */} - - {/* 顶部工具栏 */} {/* 左侧标题和面包屑 */} - + {/* 返回按钮 */} {drillPath && ( @@ -842,115 +912,46 @@ const ForceGraphView = ({ borderColor="whiteAlpha.200" boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)" > - + - 概念旭日图 + 概念矩形树图 - - {/* 面包屑导航 */} - - {breadcrumbItems.map((item, index) => ( - - {index > 0 && ( - - )} - { - if (index < breadcrumbItems.length - 1) { - setDrillPath(item.path); - } - }} - > - {item.label} - - - ))} - - - {/* 悬停信息 */} - {hoveredItem && hoveredItem.data?.level && ( - - - - - {hoveredItem.data?.level === 'lv1' ? '一级分类' : - hoveredItem.data?.level === 'lv2' ? '二级分类' : - hoveredItem.data?.level === 'lv3' ? '三级分类' : '概念'} - - - - - {hoveredItem.name} - - - {hoveredItem.data?.changePct !== undefined && hoveredItem.data?.changePct !== null && ( - 0 ? 'rgba(248, 113, 113, 0.2)' : hoveredItem.data.changePct < 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(148, 163, 184, 0.2)'} - px={3} - py={1} - borderRadius="full" - border="1px solid" - borderColor={hoveredItem.data.changePct > 0 ? 'red.400' : hoveredItem.data.changePct < 0 ? 'green.400' : 'gray.500'} - > - 0 ? FaArrowUp : hoveredItem.data.changePct < 0 ? FaArrowDown : FaCircle} - color={hoveredItem.data.changePct > 0 ? 'red.400' : hoveredItem.data.changePct < 0 ? 'green.400' : 'gray.400'} - boxSize={3} - /> - 0 ? 'red.300' : hoveredItem.data.changePct < 0 ? 'green.300' : 'gray.300'} - fontWeight="bold" - fontSize="lg" - fontFamily="mono" - > - {formatChangePercent(hoveredItem.data.changePct)} - - - )} - - - {hoveredItem.data?.stockCount && ( - {hoveredItem.data.stockCount} 只股票 - )} - {hoveredItem.data?.conceptCount && ( - {hoveredItem.data.conceptCount} 个概念 + {breadcrumbItems.map((item, index) => ( + + {index > 0 && ( + )} + { + if (index < breadcrumbItems.length - 1) { + setDrillPath(item.path); + } + }} + > + {item.label} + - - - )} + ))} + + {/* 右侧控制按钮 */} @@ -1024,7 +1025,7 @@ const ForceGraphView = ({ borderColor="whiteAlpha.200" spacing={2} > - + - + @@ -1060,7 +1061,7 @@ const ForceGraphView = ({ borderColor="whiteAlpha.200" > - 点击扇区进入 · 点击概念查看详情 + 点击分类进入 · 点击概念查看详情