diff --git a/package.json b/package.json index 30327df4..514c2210 100755 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "react-tagsinput": "3.19.0", "react-to-print": "^3.0.3", "react-tsparticles": "^2.12.2", + "reagraph": "^4.27.0", "recharts": "^3.1.2", "remark-gfm": "^4.0.1", "sass": "^1.49.9", diff --git a/src/views/Concept/components/ForceGraphView.js b/src/views/Concept/components/ForceGraphView.js index 544d9b35..c35cbb0c 100644 --- a/src/views/Concept/components/ForceGraphView.js +++ b/src/views/Concept/components/ForceGraphView.js @@ -1,15 +1,20 @@ /** - * ForceGraphView - 3D 力导向图概念层级视图 + * ForceGraphView - 概念层级关系图(使用 reagraph) * * 特性: - * 1. 3D 星空效果展示概念层级关系 + * 1. 清晰的层级布局(Radial/Hierarchical) * 2. 节点大小根据股票数量动态调整 - * 3. 节点颜色根据涨跌幅显示(涨红跌绿) - * 4. 支持鼠标交互:旋转、缩放、拖拽 - * 5. 点击节点可钻取或跳转 + * 3. 涨红跌绿颜色映射 + * 4. 悬停显示涨跌幅详情 + * 5. 点击节点可聚焦或跳转 */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import ForceGraph3D from 'react-force-graph-3d'; +import { + GraphCanvas, + useSelection, + lightTheme, + darkTheme, +} from 'reagraph'; import { Box, VStack, @@ -24,16 +29,12 @@ import { Tooltip, Badge, useBreakpointValue, - Slider, - SliderTrack, - SliderFilledTrack, - SliderThumb, - Switch, + Select, FormControl, FormLabel, - Select, Collapse, useDisclosure, + Divider, } from '@chakra-ui/react'; import { FaLayerGroup, @@ -41,44 +42,39 @@ import { FaExpand, FaCompress, FaCog, - FaPlay, - FaPause, FaHome, + FaChevronUp, FaArrowUp, FaArrowDown, - FaEye, - FaEyeSlash, - FaCube, - FaChevronDown, - FaChevronUp, + FaProjectDiagram, + FaCircle, } from 'react-icons/fa'; -import * as THREE from 'three'; import { logger } from '../../../utils/logger'; // 一级分类颜色映射 const LV1_COLORS = { - '人工智能': '#8B5CF6', // 紫色 - '半导体': '#3B82F6', // 蓝色 - '机器人': '#10B981', // 绿色 - '消费电子': '#F59E0B', // 橙色 - '智能驾驶与汽车': '#EF4444', // 红色 - '新能源与电力': '#06B6D4', // 青色 - '空天经济': '#6366F1', // 靛蓝 - '国防军工': '#EC4899', // 粉色 - '政策与主题': '#14B8A6', // 青绿 - '周期与材料': '#F97316', // 深橙 - '大消费': '#A855F7', // 亮紫 - '数字经济与金融科技': '#22D3EE', // 亮青 - '全球宏观与贸易': '#84CC16', // 黄绿 - '医药健康': '#E879F9', // 玫红 - '前沿科技': '#38BDF8', // 天蓝 + '人工智能': '#8B5CF6', + '半导体': '#3B82F6', + '机器人': '#10B981', + '消费电子': '#F59E0B', + '智能驾驶与汽车': '#EF4444', + '新能源与电力': '#06B6D4', + '空天经济': '#6366F1', + '国防军工': '#EC4899', + '政策与主题': '#14B8A6', + '周期与材料': '#F97316', + '大消费': '#A855F7', + '数字经济与金融科技': '#22D3EE', + '全球宏观与贸易': '#84CC16', + '医药健康': '#E879F9', + '前沿科技': '#38BDF8', }; -// 根据涨跌幅获取颜色 -const getChangeColor = (value) => { - if (value === null || value === undefined) return '#64748B'; // 灰色 +// 根据涨跌幅获取颜色(涨红跌绿) +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'; @@ -92,7 +88,17 @@ const getChangeColor = (value) => { if (value < -1) return '#4ADE80'; if (value < 0) return '#86EFAC'; - return '#64748B'; // 平盘 + 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 返回的名称中提取纯名称 @@ -101,8 +107,59 @@ const extractPureName = (apiName) => { return apiName.replace(/^\[(一级|二级|三级)\]\s*/, ''); }; +// 格式化涨跌幅 +const formatChangePercent = (value) => { + if (value === null || value === undefined) return '--'; + const formatted = Math.abs(value).toFixed(2); + return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%'; +}; + /** - * 主组件:3D 力导向图视图 + * 自定义深色主题 + */ +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', + }, + }, +}; + +/** + * 主组件 */ const ForceGraphView = ({ apiBaseUrl, @@ -115,16 +172,13 @@ const ForceGraphView = ({ const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} }); const [priceLoading, setPriceLoading] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - const [isRotating, setIsRotating] = useState(true); - const [showLabels, setShowLabels] = useState(true); - const [nodeSize, setNodeSize] = useState(1); - const [linkOpacity, setLinkOpacity] = useState(0.3); - const [displayLevel, setDisplayLevel] = useState('all'); // 'all', 'lv1', 'lv2', 'lv3', 'concept' - const [hoveredNode, setHoveredNode] = useState(null); + const [layoutType, setLayoutType] = useState('radialOut2d'); // 'radialOut2d', 'treeTd2d', 'treeLr2d', 'forceDirected2d' + const [displayLevel, setDisplayLevel] = useState('lv2'); // 'lv1', 'lv2', 'lv3', 'all' + const [selectedNode, setSelectedNode] = useState(null); - const fgRef = useRef(); + const graphRef = useRef(); const containerRef = useRef(); - const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure(); + const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure({ defaultIsOpen: false }); const isMobile = useBreakpointValue({ base: true, md: false }); @@ -171,7 +225,6 @@ const ForceGraphView = ({ const data = await response.json(); - // 构建映射表 const lv1Map = {}; const lv2Map = {}; const lv3Map = {}; @@ -198,8 +251,6 @@ const ForceGraphView = ({ logger.info('ForceGraphView', '层级涨跌幅加载完成', { lv1Count: Object.keys(lv1Map).length, lv2Count: Object.keys(lv2Map).length, - lv3Count: Object.keys(lv3Map).length, - leafCount: Object.keys(leafMap).length, }); } catch (err) { logger.warn('ForceGraphView', '获取层级涨跌幅失败', { error: err.message }); @@ -221,159 +272,170 @@ const ForceGraphView = ({ // 构建图数据 const graphData = useMemo(() => { const nodes = []; - const links = []; + const edges = []; const { lv1Map, lv2Map, lv3Map, leafMap } = priceData; - // 添加根节点 + // 根节点 nodes.push({ id: 'root', - name: '概念中心', - level: 'root', - val: 50, - color: '#8B5CF6', + label: '概念中心', + data: { level: 'root', changePct: null }, + fill: '#8B5CF6', + size: 60, }); - // 遍历层级结构 hierarchy.forEach((lv1) => { const lv1Id = `lv1_${lv1.name}`; const lv1Price = lv1Map[lv1.name] || {}; - const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6'; + const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6'; + const lv1Color = getChangeColor(lv1Price.avg_change_pct, lv1BaseColor); - // 添加一级节点 - if (displayLevel === 'all' || displayLevel === 'lv1') { - nodes.push({ - id: lv1Id, - name: lv1.name, + // 一级节点 + nodes.push({ + id: lv1Id, + label: lv1.name, + data: { level: 'lv1', - val: Math.max(10, (lv1Price.stock_count || 100) / 10), - color: lv1Price.avg_change_pct !== undefined - ? getChangeColor(lv1Price.avg_change_pct) - : lv1Color, - baseColor: lv1Color, changePct: lv1Price.avg_change_pct, stockCount: lv1Price.stock_count, conceptCount: lv1.concept_count, - }); + baseColor: lv1BaseColor, + }, + fill: lv1Color, + size: Math.max(35, Math.min(55, 35 + (lv1Price.stock_count || 0) / 50)), + }); - links.push({ - source: 'root', - target: lv1Id, - color: lv1Color, - }); - } + edges.push({ + id: `root-${lv1Id}`, + source: 'root', + target: lv1Id, + size: 2, + }); - // 添加二级节点 - if (lv1.children && (displayLevel === 'all' || displayLevel === 'lv2')) { + // 二级节点 + if (lv1.children && (displayLevel === 'lv2' || displayLevel === 'lv3' || displayLevel === 'all')) { 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, - name: lv2.name, - level: 'lv2', - parentLv1: lv1.name, - val: Math.max(5, (lv2Price.stock_count || 50) / 15), - color: lv2Price.avg_change_pct !== undefined - ? getChangeColor(lv2Price.avg_change_pct) - : lv1Color, - baseColor: lv1Color, - changePct: lv2Price.avg_change_pct, - stockCount: lv2Price.stock_count, - conceptCount: lv2.concept_count, + label: lv2.name, + data: { + level: 'lv2', + parentLv1: lv1.name, + changePct: lv2Price.avg_change_pct, + stockCount: lv2Price.stock_count, + conceptCount: lv2.concept_count, + baseColor: lv1BaseColor, + }, + fill: lv2Color, + size: Math.max(25, Math.min(40, 25 + (lv2Price.stock_count || 0) / 80)), }); - links.push({ - source: displayLevel === 'lv2' ? 'root' : lv1Id, + edges.push({ + id: `${lv1Id}-${lv2Id}`, + source: lv1Id, target: lv2Id, - color: `${lv1Color}80`, + size: 1.5, }); - // 添加三级节点 - if (lv2.children && (displayLevel === 'all' || displayLevel === 'lv3')) { + // 三级节点 + if (lv2.children && (displayLevel === 'lv3' || displayLevel === 'all')) { 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, - name: lv3.name, - level: 'lv3', - parentLv1: lv1.name, - parentLv2: lv2.name, - val: Math.max(3, (lv3Price.stock_count || 30) / 20), - color: lv3Price.avg_change_pct !== undefined - ? getChangeColor(lv3Price.avg_change_pct) - : lv1Color, - baseColor: lv1Color, - changePct: lv3Price.avg_change_pct, - stockCount: lv3Price.stock_count, - conceptCount: lv3.concept_count, + label: lv3.name, + data: { + level: 'lv3', + parentLv1: lv1.name, + parentLv2: lv2.name, + changePct: lv3Price.avg_change_pct, + stockCount: lv3Price.stock_count, + baseColor: lv1BaseColor, + }, + fill: lv3Color, + size: Math.max(18, Math.min(30, 18 + (lv3Price.stock_count || 0) / 100)), }); - links.push({ - source: displayLevel === 'lv3' ? 'root' : lv2Id, + edges.push({ + id: `${lv2Id}-${lv3Id}`, + source: lv2Id, target: lv3Id, - color: `${lv1Color}60`, + size: 1, }); - // 添加叶子概念节点 - if (lv3.concepts && (displayLevel === 'all' || displayLevel === 'concept')) { - lv3.concepts.forEach((conceptName) => { - const conceptId = `concept_${lv1.name}_${lv2.name}_${lv3.name}_${conceptName}`; + // 叶子概念 + if (displayLevel === 'all' && lv3.concepts) { + lv3.concepts.slice(0, 5).forEach((conceptName) => { // 限制每个lv3只显示5个概念 + const conceptId = `concept_${conceptName}`; const conceptPrice = leafMap[conceptName] || {}; + const conceptColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor); - nodes.push({ - id: conceptId, - name: conceptName, - level: 'concept', - parentLv1: lv1.name, - parentLv2: lv2.name, - parentLv3: lv3.name, - val: Math.max(1, (conceptPrice.stock_count || 10) / 25), - color: conceptPrice.avg_change_pct !== undefined - ? getChangeColor(conceptPrice.avg_change_pct) - : lv1Color, - baseColor: lv1Color, - changePct: conceptPrice.avg_change_pct, - stockCount: conceptPrice.stock_count, - }); + // 避免重复添加节点 + 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, + }); + } - links.push({ - source: displayLevel === 'concept' ? 'root' : lv3Id, + edges.push({ + id: `${lv3Id}-${conceptId}`, + source: lv3Id, target: conceptId, - color: `${lv1Color}40`, + size: 0.5, }); }); } }); } - // 如果 lv2 直接包含概念(没有 lv3) - if (lv2.concepts && (displayLevel === 'all' || displayLevel === 'concept')) { - lv2.concepts.forEach((conceptName) => { - const conceptId = `concept_${lv1.name}_${lv2.name}_${conceptName}`; + // lv2 直接包含的概念 + if (displayLevel === 'all' && lv2.concepts) { + lv2.concepts.slice(0, 5).forEach((conceptName) => { + const conceptId = `concept_${conceptName}`; const conceptPrice = leafMap[conceptName] || {}; + const conceptColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor); - nodes.push({ - id: conceptId, - name: conceptName, - level: 'concept', - parentLv1: lv1.name, - parentLv2: lv2.name, - val: Math.max(1, (conceptPrice.stock_count || 10) / 25), - color: conceptPrice.avg_change_pct !== undefined - ? getChangeColor(conceptPrice.avg_change_pct) - : lv1Color, - baseColor: lv1Color, - changePct: conceptPrice.avg_change_pct, - stockCount: conceptPrice.stock_count, - }); + 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, + }); + } - links.push({ - source: displayLevel === 'concept' ? 'root' : lv2Id, + edges.push({ + id: `${lv2Id}-${conceptId}`, + source: lv2Id, target: conceptId, - color: `${lv1Color}40`, + size: 0.5, }); }); } @@ -381,125 +443,31 @@ const ForceGraphView = ({ } }); - return { nodes, links }; + return { nodes, edges }; }, [hierarchy, priceData, displayLevel]); - // 自动旋转 - useEffect(() => { - if (fgRef.current && isRotating) { - const controls = fgRef.current.controls(); - if (controls) { - controls.autoRotate = true; - controls.autoRotateSpeed = 0.5; - } - } else if (fgRef.current) { - const controls = fgRef.current.controls(); - if (controls) { - controls.autoRotate = false; - } - } - }, [isRotating]); - - // 处理节点点击 + // 节点点击处理 const handleNodeClick = useCallback((node) => { - if (node.level === 'concept') { - // 跳转到概念详情页 - const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(node.name)}.html`; + setSelectedNode(node); + + if (node.data?.level === 'concept') { + const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(node.label)}.html`; window.open(htmlPath, '_blank'); - } else if (node.level === 'root') { - // 重置视图 - if (fgRef.current) { - fgRef.current.cameraPosition({ x: 0, y: 0, z: 500 }, { x: 0, y: 0, z: 0 }, 1000); - } - } else { - // 聚焦到节点 - if (fgRef.current) { - const distance = 200; - const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); - fgRef.current.cameraPosition( - { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }, - node, - 1000 - ); - } } - logger.info('ForceGraphView', '节点点击', { level: node.level, name: node.name }); + logger.info('ForceGraphView', '节点点击', { level: node.data?.level, name: node.label }); }, []); - // 自定义节点渲染 - const nodeThreeObject = useCallback((node) => { - if (node.level === 'root') { - // 根节点使用特殊球体 - const geometry = new THREE.SphereGeometry(8); - const material = new THREE.MeshBasicMaterial({ - color: node.color, - transparent: true, - opacity: 0.9, - }); - const sphere = new THREE.Mesh(geometry, material); - - // 添加发光效果 - const glowGeometry = new THREE.SphereGeometry(12); - const glowMaterial = new THREE.MeshBasicMaterial({ - color: node.color, - transparent: true, - opacity: 0.2, - }); - const glow = new THREE.Mesh(glowGeometry, glowMaterial); - sphere.add(glow); - - return sphere; - } - - const size = (node.val || 5) * nodeSize; - const geometry = new THREE.SphereGeometry(size); - const material = new THREE.MeshBasicMaterial({ - color: node.color, - transparent: true, - opacity: node.level === 'concept' ? 0.7 : 0.85, - }); - const sphere = new THREE.Mesh(geometry, material); - - // 为大节点添加发光效果 - if (size > 5) { - const glowGeometry = new THREE.SphereGeometry(size * 1.3); - const glowMaterial = new THREE.MeshBasicMaterial({ - color: node.color, - transparent: true, - opacity: 0.15, - }); - const glow = new THREE.Mesh(glowGeometry, glowMaterial); - sphere.add(glow); - } - - return sphere; - }, [nodeSize]); - // 刷新数据 const handleRefresh = useCallback(() => { fetchHierarchyPrice(); }, [fetchHierarchyPrice]); - // 重置视图 - const handleResetView = useCallback(() => { - if (fgRef.current) { - fgRef.current.cameraPosition({ x: 0, y: 0, z: 500 }, { x: 0, y: 0, z: 0 }, 1000); - } - }, []); - // 全屏切换 const toggleFullscreen = useCallback(() => { setIsFullscreen(prev => !prev); }, []); - // 格式化涨跌幅 - const formatChangePercent = (value) => { - if (value === null || value === undefined) return '--'; - const formatted = Math.abs(value).toFixed(2); - return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%'; - }; - // 获取容器高度 const containerHeight = useMemo(() => { if (isFullscreen) return '100vh'; @@ -508,10 +476,10 @@ const ForceGraphView = ({ if (loading) { return ( -