diff --git a/src/views/Concept/components/ForceGraphView.js b/src/views/Concept/components/ForceGraphView.js index c35cbb0c..72774167 100644 --- a/src/views/Concept/components/ForceGraphView.js +++ b/src/views/Concept/components/ForceGraphView.js @@ -1,20 +1,15 @@ /** - * ForceGraphView - 概念层级关系图(使用 reagraph) + * SunburstView - 概念层级旭日图 * * 特性: - * 1. 清晰的层级布局(Radial/Hierarchical) - * 2. 节点大小根据股票数量动态调整 - * 3. 涨红跌绿颜色映射 - * 4. 悬停显示涨跌幅详情 - * 5. 点击节点可聚焦或跳转 + * 1. 同心圆环展示层级关系,从内到外:根 → 一级 → 二级 → 三级 → 概念 + * 2. 涨红跌绿颜色映射 + * 3. 点击扇区可钻取到子层级 + * 4. 悬停显示详细信息 + * 5. 支持返回上级 */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { - GraphCanvas, - useSelection, - lightTheme, - darkTheme, -} from 'reagraph'; +import ReactECharts from 'echarts-for-react'; import { Box, VStack, @@ -29,29 +24,22 @@ import { Tooltip, Badge, useBreakpointValue, - Select, - FormControl, - FormLabel, - Collapse, - useDisclosure, - Divider, } from '@chakra-ui/react'; import { FaLayerGroup, FaSync, FaExpand, FaCompress, - FaCog, FaHome, - FaChevronUp, FaArrowUp, FaArrowDown, - FaProjectDiagram, FaCircle, + FaChartPie, + FaUndo, } from 'react-icons/fa'; import { logger } from '../../../utils/logger'; -// 一级分类颜色映射 +// 一级分类颜色映射(基础色) const LV1_COLORS = { '人工智能': '#8B5CF6', '半导体': '#3B82F6', @@ -74,33 +62,23 @@ const LV1_COLORS = { 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 > 0) return '#FED7D7'; // 跌 - 绿色系 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'; + if (value < 0) return '#BBF7D0'; return baseColor; // 平盘 }; -// 根据涨跌幅获取发光颜色 -const getGlowColor = (value) => { - if (value === null || value === undefined) return 'rgba(100, 116, 139, 0.5)'; - if (value > 3) return 'rgba(239, 68, 68, 0.6)'; - if (value > 0) return 'rgba(252, 165, 165, 0.5)'; - if (value < -3) return 'rgba(34, 197, 94, 0.6)'; - if (value < 0) return 'rgba(134, 239, 172, 0.5)'; - return 'rgba(100, 116, 139, 0.5)'; -}; - // 从 API 返回的名称中提取纯名称 const extractPureName = (apiName) => { if (!apiName) return ''; @@ -114,50 +92,6 @@ const formatChangePercent = (value) => { return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%'; }; -/** - * 自定义深色主题 - */ -const customDarkTheme = { - ...darkTheme, - canvas: { - background: 'transparent', - }, - node: { - ...darkTheme.node, - fill: '#8B5CF6', - activeFill: '#A78BFA', - label: { - color: '#E2E8F0', - stroke: '#0F172A', - activeColor: '#FFFFFF', - }, - }, - edge: { - ...darkTheme.edge, - fill: '#475569', - activeFill: '#8B5CF6', - }, - ring: { - ...darkTheme.ring, - fill: '#8B5CF6', - activeFill: '#A78BFA', - }, - arrow: { - ...darkTheme.arrow, - fill: '#475569', - activeFill: '#8B5CF6', - }, - cluster: { - ...darkTheme.cluster, - stroke: '#475569', - fill: 'rgba(139, 92, 246, 0.1)', - label: { - ...darkTheme.cluster?.label, - color: '#94A3B8', - }, - }, -}; - /** * 主组件 */ @@ -172,13 +106,11 @@ const ForceGraphView = ({ const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} }); const [priceLoading, setPriceLoading] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - const [layoutType, setLayoutType] = useState('radialOut2d'); // 'radialOut2d', 'treeTd2d', 'treeLr2d', 'forceDirected2d' - const [displayLevel, setDisplayLevel] = useState('lv2'); // 'lv1', 'lv2', 'lv3', 'all' - const [selectedNode, setSelectedNode] = useState(null); + const [hoveredItem, setHoveredItem] = useState(null); + const [drillPath, setDrillPath] = useState([]); // 钻取路径 - const graphRef = useRef(); + const chartRef = useRef(); const containerRef = useRef(); - const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure({ defaultIsOpen: false }); const isMobile = useBreakpointValue({ base: true, md: false }); @@ -194,12 +126,12 @@ const ForceGraphView = ({ const data = await response.json(); setHierarchy(data.hierarchy || []); - logger.info('ForceGraphView', '层级结构加载完成', { + logger.info('SunburstView', '层级结构加载完成', { totalLv1: data.hierarchy?.length, totalConcepts: data.total_concepts }); } catch (err) { - logger.error('ForceGraphView', 'fetchHierarchy', err); + logger.error('SunburstView', 'fetchHierarchy', err); setError(err.message); } finally { setLoading(false); @@ -219,7 +151,7 @@ const ForceGraphView = ({ const response = await fetch(url); if (!response.ok) { - logger.warn('ForceGraphView', '获取层级涨跌幅失败', { status: response.status }); + logger.warn('SunburstView', '获取层级涨跌幅失败', { status: response.status }); return; } @@ -248,12 +180,12 @@ const ForceGraphView = ({ setPriceData({ lv1Map, lv2Map, lv3Map, leafMap }); - logger.info('ForceGraphView', '层级涨跌幅加载完成', { + logger.info('SunburstView', '层级涨跌幅加载完成', { lv1Count: Object.keys(lv1Map).length, lv2Count: Object.keys(lv2Map).length, }); } catch (err) { - logger.warn('ForceGraphView', '获取层级涨跌幅失败', { error: err.message }); + logger.warn('SunburstView', '获取层级涨跌幅失败', { error: err.message }); } finally { setPriceLoading(false); } @@ -269,31 +201,20 @@ const ForceGraphView = ({ } }, [hierarchy, fetchHierarchyPrice]); - // 构建图数据 - const graphData = useMemo(() => { - const nodes = []; - const edges = []; + // 构建旭日图数据 + const sunburstData = useMemo(() => { const { lv1Map, lv2Map, lv3Map, leafMap } = priceData; - // 根节点 - nodes.push({ - id: 'root', - label: '概念中心', - data: { level: 'root', changePct: null }, - fill: '#8B5CF6', - size: 60, - }); - - hierarchy.forEach((lv1) => { - const lv1Id = `lv1_${lv1.name}`; - const lv1Price = lv1Map[lv1.name] || {}; + const buildChildren = (lv1) => { const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6'; - const lv1Color = getChangeColor(lv1Price.avg_change_pct, lv1BaseColor); + const lv1Price = lv1Map[lv1.name] || {}; - // 一级节点 - nodes.push({ - id: lv1Id, - label: 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, @@ -301,27 +222,20 @@ const ForceGraphView = ({ conceptCount: lv1.concept_count, baseColor: lv1BaseColor, }, - fill: lv1Color, - size: Math.max(35, Math.min(55, 35 + (lv1Price.stock_count || 0) / 50)), - }); + children: [], + }; - edges.push({ - id: `root-${lv1Id}`, - source: 'root', - target: lv1Id, - size: 2, - }); - - // 二级节点 - if (lv1.children && (displayLevel === 'lv2' || displayLevel === 'lv3' || displayLevel === 'all')) { + // 二级 + if (lv1.children) { lv1.children.forEach((lv2) => { - const lv2Id = `lv2_${lv1.name}_${lv2.name}`; const lv2Price = lv2Map[lv2.name] || {}; - const lv2Color = getChangeColor(lv2Price.avg_change_pct, lv1BaseColor); - nodes.push({ - id: lv2Id, - label: 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, @@ -330,27 +244,20 @@ const ForceGraphView = ({ conceptCount: lv2.concept_count, baseColor: lv1BaseColor, }, - fill: lv2Color, - size: Math.max(25, Math.min(40, 25 + (lv2Price.stock_count || 0) / 80)), - }); + children: [], + }; - edges.push({ - id: `${lv1Id}-${lv2Id}`, - source: lv1Id, - target: lv2Id, - size: 1.5, - }); - - // 三级节点 - if (lv2.children && (displayLevel === 'lv3' || displayLevel === 'all')) { + // 三级 + if (lv2.children) { lv2.children.forEach((lv3) => { - const lv3Id = `lv3_${lv1.name}_${lv2.name}_${lv3.name}`; const lv3Price = lv3Map[lv3.name] || {}; - const lv3Color = getChangeColor(lv3Price.avg_change_pct, lv1BaseColor); - nodes.push({ - id: lv3Id, - label: 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, @@ -359,104 +266,264 @@ const ForceGraphView = ({ stockCount: lv3Price.stock_count, baseColor: lv1BaseColor, }, - fill: lv3Color, - size: Math.max(18, Math.min(30, 18 + (lv3Price.stock_count || 0) / 100)), - }); - - edges.push({ - id: `${lv2Id}-${lv3Id}`, - source: lv2Id, - target: lv3Id, - size: 1, - }); + children: [], + }; // 叶子概念 - if (displayLevel === 'all' && lv3.concepts) { - lv3.concepts.slice(0, 5).forEach((conceptName) => { // 限制每个lv3只显示5个概念 - const conceptId = `concept_${conceptName}`; + if (lv3.concepts) { + lv3.concepts.forEach((conceptName) => { const conceptPrice = leafMap[conceptName] || {}; - const conceptColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor); - - // 避免重复添加节点 - if (!nodes.find(n => n.id === conceptId)) { - nodes.push({ - id: conceptId, - label: conceptName, - data: { - level: 'concept', - parentLv1: lv1.name, - parentLv2: lv2.name, - parentLv3: lv3.name, - changePct: conceptPrice.avg_change_pct, - stockCount: conceptPrice.stock_count, - baseColor: lv1BaseColor, - }, - fill: conceptColor, - size: 12, - }); - } - - edges.push({ - id: `${lv3Id}-${conceptId}`, - source: lv3Id, - target: conceptId, - size: 0.5, + 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 (displayLevel === 'all' && lv2.concepts) { - lv2.concepts.slice(0, 5).forEach((conceptName) => { - const conceptId = `concept_${conceptName}`; + if (lv2.concepts) { + lv2.concepts.forEach((conceptName) => { const conceptPrice = leafMap[conceptName] || {}; - const conceptColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor); - - if (!nodes.find(n => n.id === conceptId)) { - nodes.push({ - id: conceptId, - label: conceptName, - data: { - level: 'concept', - parentLv1: lv1.name, - parentLv2: lv2.name, - changePct: conceptPrice.avg_change_pct, - stockCount: conceptPrice.stock_count, - baseColor: lv1BaseColor, - }, - fill: conceptColor, - size: 12, - }); - } - - edges.push({ - id: `${lv2Id}-${conceptId}`, - source: lv2Id, - target: conceptId, - size: 0.5, + 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, + }, }); }); } + + lv1Node.children.push(lv2Node); }); } - }); - return { nodes, edges }; - }, [hierarchy, priceData, displayLevel]); + return lv1Node; + }; - // 节点点击处理 - const handleNodeClick = useCallback((node) => { - setSelectedNode(node); + return hierarchy.map(buildChildren); + }, [hierarchy, priceData]); - if (node.data?.level === 'concept') { - const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(node.label)}.html`; - window.open(htmlPath, '_blank'); - } + // ECharts 配置 + const chartOption = useMemo(() => { + 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': '概念', + }; - logger.info('ForceGraphView', '节点点击', { level: node.data?.level, name: node.label }); - }, []); + const changePct = data.changePct; + const changeColor = changePct > 0 ? '#F87171' : changePct < 0 ? '#4ADE80' : '#94A3B8'; + const changeIcon = changePct > 0 ? '▲' : changePct < 0 ? '▼' : '●'; + + return ` +