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 = ({ ` : `
- 点击钻取到下级 + 点击进入查看详情
`} @@ -407,7 +641,7 @@ const ForceGraphView = ({ { type: 'sunburst', data: sunburstData, - radius: ['12%', '95%'], + radius: ['15%', '90%'], center: ['50%', '50%'], sort: 'desc', emphasis: { @@ -417,89 +651,17 @@ const ForceGraphView = ({ shadowColor: 'rgba(139, 92, 246, 0.5)', }, }, - levels: [ - {}, - // 一级 - 最内圈 - { - r0: '12%', - r: '35%', - itemStyle: { - borderWidth: 3, - borderColor: '#0F172A', - borderRadius: 4, - }, - label: { - show: true, - rotate: 'tangential', - fontSize: 12, - fontWeight: 'bold', - color: '#FFFFFF', - textShadowColor: '#000', - textShadowBlur: 4, - }, - }, - // 二级 - { - r0: '35%', - r: '58%', - itemStyle: { - borderWidth: 2, - borderColor: '#0F172A', - borderRadius: 3, - }, - label: { - show: true, - rotate: 'tangential', - fontSize: 10, - color: '#F1F5F9', - textShadowColor: '#000', - textShadowBlur: 3, - }, - }, - // 三级 - { - r0: '58%', - r: '78%', - itemStyle: { - borderWidth: 1, - borderColor: '#0F172A', - borderRadius: 2, - }, - label: { - show: true, - rotate: 'radial', - fontSize: 9, - color: '#E2E8F0', - align: 'center', - }, - }, - // 概念 - 最外圈 - { - r0: '78%', - r: '95%', - itemStyle: { - borderWidth: 1, - borderColor: '#1E293B', - borderRadius: 1, - }, - label: { - show: false, // 概念层太多,隐藏标签 - position: 'outside', - fontSize: 8, - color: '#94A3B8', - }, - }, - ], + levels: styledLevels, itemStyle: { borderRadius: 4, }, animation: true, - animationDuration: 800, + animationDuration: 600, animationEasing: 'cubicOut', }, ], }; - }, [sunburstData]); + }, [sunburstData, currentLevels]); // 图表事件 const onChartEvents = useMemo(() => ({ @@ -510,9 +672,19 @@ const ForceGraphView = ({ // 跳转到概念详情页 const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(params.name)}.html`; window.open(htmlPath, '_blank'); + return; } - logger.info('SunburstView', '点击', { level: data.level, name: params.name }); + // 钻取进入 + 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('SunburstView', '钻取', { level: data.level, name: params.name, path: drillPath }); }, mouseover: (params) => { setHoveredItem({ @@ -523,7 +695,25 @@ const ForceGraphView = ({ mouseout: () => { setHoveredItem(null); }, - }), []); + }), [drillPath]); + + // 返回上一层 + 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(() => { @@ -535,6 +725,23 @@ const ForceGraphView = ({ 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'; @@ -605,8 +812,44 @@ const ForceGraphView = ({ zIndex={10} pointerEvents="none" > - {/* 左侧标题 */} + {/* 左侧标题和面包屑 */} + + {/* 返回按钮 */} + {drillPath && ( + + } + 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="返回" + /> + + )} + + + + + 概念旭日图 + + + + + {/* 面包屑导航 */} - - - 概念旭日图 - - - {hierarchy.length} 板块 - + {breadcrumbItems.map((item, index) => ( + + {index > 0 && ( + + )} + { + if (index < breadcrumbItems.length - 1) { + setDrillPath(item.path); + } + }} + > + {item.label} + + + ))} {/* 悬停信息 */} - {hoveredItem && hoveredItem.data?.level !== 'root' && ( + {hoveredItem && hoveredItem.data?.level && ( {priceLoading && } + {drillPath && ( + + } + 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="返回全部" + /> + + )} + - - - 平/无数据 - {/* 操作提示 */} @@ -806,52 +1060,11 @@ const ForceGraphView = ({ borderColor="whiteAlpha.200" > - 悬停查看详情 · 点击概念跳转 + 点击扇区进入 · 点击概念查看详情 - {/* 层级说明 */} - - - - - 一级 - - - - - 二级 - - - - - 三级 - - - - - 概念 - - - - {/* ECharts 图表 */}