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