diff --git a/package.json b/package.json index c0210101..30327df4 100755 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-circular-slider-svg": "^0.1.5", "react-custom-scrollbars-2": "^4.4.0", "react-dom": "^19.0.0", + "react-force-graph-3d": "^1.29.0", "react-github-btn": "^1.2.1", "react-icons": "^4.12.0", "react-input-pin-code": "^1.1.5", @@ -76,6 +77,7 @@ "styled-components": "^5.3.11", "stylis": "^4.0.10", "stylis-plugin-rtl": "^2.1.1", + "three": "^0.181.2", "typescript": "^5.9.3" }, "resolutions": { diff --git a/src/views/Concept/components/ForceGraphView.js b/src/views/Concept/components/ForceGraphView.js new file mode 100644 index 00000000..544d9b35 --- /dev/null +++ b/src/views/Concept/components/ForceGraphView.js @@ -0,0 +1,918 @@ +/** + * ForceGraphView - 3D 力导向图概念层级视图 + * + * 特性: + * 1. 3D 星空效果展示概念层级关系 + * 2. 节点大小根据股票数量动态调整 + * 3. 节点颜色根据涨跌幅显示(涨红跌绿) + * 4. 支持鼠标交互:旋转、缩放、拖拽 + * 5. 点击节点可钻取或跳转 + */ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import ForceGraph3D from 'react-force-graph-3d'; +import { + Box, + VStack, + HStack, + Text, + Spinner, + Center, + Icon, + Flex, + Button, + IconButton, + Tooltip, + Badge, + useBreakpointValue, + Slider, + SliderTrack, + SliderFilledTrack, + SliderThumb, + Switch, + FormControl, + FormLabel, + Select, + Collapse, + useDisclosure, +} from '@chakra-ui/react'; +import { + FaLayerGroup, + FaSync, + FaExpand, + FaCompress, + FaCog, + FaPlay, + FaPause, + FaHome, + FaArrowUp, + FaArrowDown, + FaEye, + FaEyeSlash, + FaCube, + FaChevronDown, + FaChevronUp, +} 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', // 天蓝 +}; + +// 根据涨跌幅获取颜色 +const getChangeColor = (value) => { + if (value === null || value === undefined) return '#64748B'; // 灰色 + + // 涨 - 红色系 + 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 < -7) return '#15803D'; + if (value < -5) return '#16A34A'; + if (value < -3) return '#22C55E'; + if (value < -1) return '#4ADE80'; + if (value < 0) return '#86EFAC'; + + return '#64748B'; // 平盘 +}; + +// 从 API 返回的名称中提取纯名称 +const extractPureName = (apiName) => { + if (!apiName) return ''; + return apiName.replace(/^\[(一级|二级|三级)\]\s*/, ''); +}; + +/** + * 主组件:3D 力导向图视图 + */ +const ForceGraphView = ({ + apiBaseUrl, + onSelectCategory, + selectedDate, +}) => { + const [hierarchy, setHierarchy] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + 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 fgRef = useRef(); + const containerRef = useRef(); + const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure(); + + const isMobile = useBreakpointValue({ base: true, md: false }); + + // 获取层级结构数据 + const fetchHierarchy = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${apiBaseUrl}/hierarchy`); + if (!response.ok) throw new Error('获取层级结构失败'); + + const data = await response.json(); + setHierarchy(data.hierarchy || []); + + logger.info('ForceGraphView', '层级结构加载完成', { + totalLv1: data.hierarchy?.length, + totalConcepts: data.total_concepts + }); + } catch (err) { + logger.error('ForceGraphView', 'fetchHierarchy', err); + setError(err.message); + } finally { + setLoading(false); + } + }, [apiBaseUrl]); + + // 获取层级涨跌幅数据 + const fetchHierarchyPrice = useCallback(async () => { + setPriceLoading(true); + + try { + let url = `${apiBaseUrl}/hierarchy/price`; + if (selectedDate) { + const dateStr = selectedDate.toISOString().split('T')[0]; + url += `?trade_date=${dateStr}`; + } + + const response = await fetch(url); + if (!response.ok) { + logger.warn('ForceGraphView', '获取层级涨跌幅失败', { status: response.status }); + return; + } + + const data = await response.json(); + + // 构建映射表 + const lv1Map = {}; + const lv2Map = {}; + const lv3Map = {}; + const leafMap = {}; + + (data.lv1_concepts || []).forEach(item => { + const pureName = extractPureName(item.concept_name); + lv1Map[pureName] = item; + }); + (data.lv2_concepts || []).forEach(item => { + const pureName = extractPureName(item.concept_name); + lv2Map[pureName] = item; + }); + (data.lv3_concepts || []).forEach(item => { + const pureName = extractPureName(item.concept_name); + lv3Map[pureName] = item; + }); + (data.leaf_concepts || []).forEach(item => { + leafMap[item.concept_name] = item; + }); + + setPriceData({ lv1Map, lv2Map, lv3Map, leafMap }); + + 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 }); + } finally { + setPriceLoading(false); + } + }, [apiBaseUrl, selectedDate]); + + useEffect(() => { + fetchHierarchy(); + }, [fetchHierarchy]); + + useEffect(() => { + if (hierarchy.length > 0) { + fetchHierarchyPrice(); + } + }, [hierarchy, fetchHierarchyPrice]); + + // 构建图数据 + const graphData = useMemo(() => { + const nodes = []; + const links = []; + const { lv1Map, lv2Map, lv3Map, leafMap } = priceData; + + // 添加根节点 + nodes.push({ + id: 'root', + name: '概念中心', + level: 'root', + val: 50, + color: '#8B5CF6', + }); + + // 遍历层级结构 + hierarchy.forEach((lv1) => { + const lv1Id = `lv1_${lv1.name}`; + const lv1Price = lv1Map[lv1.name] || {}; + const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6'; + + // 添加一级节点 + if (displayLevel === 'all' || displayLevel === 'lv1') { + nodes.push({ + id: lv1Id, + name: lv1.name, + 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, + }); + + links.push({ + source: 'root', + target: lv1Id, + color: lv1Color, + }); + } + + // 添加二级节点 + if (lv1.children && (displayLevel === 'all' || displayLevel === 'lv2')) { + lv1.children.forEach((lv2) => { + const lv2Id = `lv2_${lv1.name}_${lv2.name}`; + const lv2Price = lv2Map[lv2.name] || {}; + + 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, + }); + + links.push({ + source: displayLevel === 'lv2' ? 'root' : lv1Id, + target: lv2Id, + color: `${lv1Color}80`, + }); + + // 添加三级节点 + if (lv2.children && (displayLevel === 'all' || displayLevel === 'lv3')) { + lv2.children.forEach((lv3) => { + const lv3Id = `lv3_${lv1.name}_${lv2.name}_${lv3.name}`; + const lv3Price = lv3Map[lv3.name] || {}; + + 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, + }); + + links.push({ + source: displayLevel === 'lv3' ? 'root' : lv2Id, + target: lv3Id, + color: `${lv1Color}60`, + }); + + // 添加叶子概念节点 + if (lv3.concepts && (displayLevel === 'all' || displayLevel === 'concept')) { + lv3.concepts.forEach((conceptName) => { + const conceptId = `concept_${lv1.name}_${lv2.name}_${lv3.name}_${conceptName}`; + const conceptPrice = leafMap[conceptName] || {}; + + 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, + }); + + links.push({ + source: displayLevel === 'concept' ? 'root' : lv3Id, + target: conceptId, + color: `${lv1Color}40`, + }); + }); + } + }); + } + + // 如果 lv2 直接包含概念(没有 lv3) + if (lv2.concepts && (displayLevel === 'all' || displayLevel === 'concept')) { + lv2.concepts.forEach((conceptName) => { + const conceptId = `concept_${lv1.name}_${lv2.name}_${conceptName}`; + const conceptPrice = leafMap[conceptName] || {}; + + 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, + }); + + links.push({ + source: displayLevel === 'concept' ? 'root' : lv2Id, + target: conceptId, + color: `${lv1Color}40`, + }); + }); + } + }); + } + }); + + return { nodes, links }; + }, [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`; + 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 }); + }, []); + + // 自定义节点渲染 + 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'; + return isMobile ? '500px' : '700px'; + }, [isFullscreen, isMobile]); + + if (loading) { + return ( +