diff --git a/src/views/Concept/components/ForceGraphView.js b/src/views/Concept/components/ForceGraphView.js index 72774167..45a9a372 100644 --- a/src/views/Concept/components/ForceGraphView.js +++ b/src/views/Concept/components/ForceGraphView.js @@ -1,12 +1,11 @@ /** - * SunburstView - 概念层级旭日图 + * SunburstView - 概念层级旭日图(支持钻取) * * 特性: - * 1. 同心圆环展示层级关系,从内到外:根 → 一级 → 二级 → 三级 → 概念 - * 2. 涨红跌绿颜色映射 - * 3. 点击扇区可钻取到子层级 - * 4. 悬停显示详细信息 - * 5. 支持返回上级 + * 1. 默认显示3层(一级 → 二级 → 三级) + * 2. 点击扇区钻取进入,显示该分类下的子级 + * 3. 支持返回上级 + * 4. 涨红跌绿颜色映射 */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import ReactECharts from 'echarts-for-react'; @@ -24,6 +23,9 @@ import { Tooltip, Badge, useBreakpointValue, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, } from '@chakra-ui/react'; import { FaLayerGroup, @@ -36,7 +38,10 @@ import { FaCircle, FaChartPie, FaUndo, + FaChevronRight, + FaArrowLeft, } from 'react-icons/fa'; +import { ChevronRightIcon } from '@chakra-ui/icons'; import { logger } from '../../../utils/logger'; // 一级分类颜色映射(基础色) @@ -76,7 +81,7 @@ const getChangeColor = (value, baseColor = '#64748B') => { if (value < -1) return '#4ADE80'; if (value < 0) return '#BBF7D0'; - return baseColor; // 平盘 + return baseColor; }; // 从 API 返回的名称中提取纯名称 @@ -107,7 +112,13 @@ const ForceGraphView = ({ const [priceLoading, setPriceLoading] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [hoveredItem, setHoveredItem] = useState(null); - const [drillPath, setDrillPath] = useState([]); // 钻取路径 + + // 钻取状态:记录当前查看的路径 + // null = 根视图(显示所有一级) + // { lv1: '人工智能' } = 查看人工智能下的二级和三级 + // { lv1: '人工智能', lv2: 'AI基础设施' } = 查看AI基础设施下的三级和概念 + // { lv1: '人工智能', lv2: 'AI基础设施', lv3: 'AI芯片' } = 查看AI芯片下的概念 + const [drillPath, setDrillPath] = useState(null); const chartRef = useRef(); const containerRef = useRef(); @@ -179,11 +190,6 @@ const ForceGraphView = ({ }); setPriceData({ lv1Map, lv2Map, lv3Map, leafMap }); - - logger.info('SunburstView', '层级涨跌幅加载完成', { - lv1Count: Object.keys(lv1Map).length, - lv2Count: Object.keys(lv2Map).length, - }); } catch (err) { logger.warn('SunburstView', '获取层级涨跌幅失败', { error: err.message }); } finally { @@ -201,106 +207,228 @@ const ForceGraphView = ({ } }, [hierarchy, fetchHierarchyPrice]); - // 构建旭日图数据 + // 根据钻取路径构建旭日图数据 const sunburstData = useMemo(() => { const { lv1Map, lv2Map, lv3Map, leafMap } = priceData; - const buildChildren = (lv1) => { + // 根视图:显示所有一级,每个一级下显示二级和三级(不显示概念) + 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), + }, + 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), + }, + 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), + }, + 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'; - 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), - }, - data: { - level: 'lv1', - changePct: lv1Price.avg_change_pct, - stockCount: lv1Price.stock_count, - conceptCount: lv1.concept_count, - baseColor: lv1BaseColor, - }, - children: [], - }; + return (lv1.children || []).map((lv2) => { + const lv2Price = lv2Map[lv2.name] || {}; - // 二级 - 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), + }, + data: { + level: 'lv2', + parentLv1: lv1.name, + changePct: lv2Price.avg_change_pct, + stockCount: lv2Price.stock_count, + conceptCount: lv2.concept_count, + baseColor: lv1BaseColor, + }, + children: [], + }; - const lv2Node = { - name: lv2.name, - value: lv2Price.stock_count || lv2.concept_count * 5 || 50, + // 三级 + 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), + }, + 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), + }, + 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), + }, + 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(lv2Price.avg_change_pct, lv1BaseColor), + color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor), }, data: { - level: 'lv2', + level: 'lv3', parentLv1: lv1.name, - changePct: lv2Price.avg_change_pct, - stockCount: lv2Price.stock_count, - conceptCount: lv2.concept_count, + parentLv2: lv2.name, + changePct: lv3Price.avg_change_pct, + stockCount: lv3Price.stock_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), - }, - 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), - }, - 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) => { + // 概念 + if (lv3.concepts) { + lv3.concepts.forEach((conceptName) => { const conceptPrice = leafMap[conceptName] || {}; - lv2Node.children.push({ + lv3Node.children.push({ name: conceptName, value: conceptPrice.stock_count || 10, itemStyle: { @@ -310,6 +438,7 @@ const ForceGraphView = ({ level: 'concept', parentLv1: lv1.name, parentLv2: lv2.name, + parentLv3: lv3.name, changePct: conceptPrice.avg_change_pct, stockCount: conceptPrice.stock_count, baseColor: lv1BaseColor, @@ -318,18 +447,123 @@ const ForceGraphView = ({ }); } - lv1Node.children.push(lv2Node); + result.push(lv3Node); }); } - return lv1Node; - }; + // 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), + }, + data: { + level: 'concept', + parentLv1: lv1.name, + parentLv2: lv2.name, + changePct: conceptPrice.avg_change_pct, + stockCount: conceptPrice.stock_count, + baseColor: lv1BaseColor, + }, + }); + }); + } - return hierarchy.map(buildChildren); - }, [hierarchy, priceData]); + 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), + }, + 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(() => { + // 根据当前层级数调整半径 + 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, + itemStyle: { + borderWidth: index === 1 ? 3 : index === 2 ? 2 : 1, + borderColor: '#0F172A', + borderRadius: 4, + }, + label: { + ...level.label, + color: '#FFFFFF', + textShadowColor: '#000', + textShadowBlur: 4, + }, + }; + }); + return { backgroundColor: 'transparent', tooltip: { @@ -396,7 +630,7 @@ const ForceGraphView = ({ ` : `