/** * ForceGraphView - 概念层级关系图(使用 reagraph) * * 特性: * 1. 清晰的层级布局(Radial/Hierarchical) * 2. 节点大小根据股票数量动态调整 * 3. 涨红跌绿颜色映射 * 4. 悬停显示涨跌幅详情 * 5. 点击节点可聚焦或跳转 */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { GraphCanvas, useSelection, lightTheme, darkTheme, } from 'reagraph'; import { Box, VStack, HStack, Text, Spinner, Center, Icon, Flex, Button, IconButton, 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, } from 'react-icons/fa'; 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, 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 < -7) return '#15803D'; if (value < -5) return '#16A34A'; if (value < -3) return '#22C55E'; if (value < -1) return '#4ADE80'; if (value < 0) return '#86EFAC'; 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 ''; 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%'; }; /** * 自定义深色主题 */ 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, 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 [layoutType, setLayoutType] = useState('radialOut2d'); // 'radialOut2d', 'treeTd2d', 'treeLr2d', 'forceDirected2d' const [displayLevel, setDisplayLevel] = useState('lv2'); // 'lv1', 'lv2', 'lv3', 'all' const [selectedNode, setSelectedNode] = useState(null); const graphRef = useRef(); const containerRef = useRef(); const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure({ defaultIsOpen: false }); 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, }); } 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 edges = []; 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 lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6'; const lv1Color = getChangeColor(lv1Price.avg_change_pct, lv1BaseColor); // 一级节点 nodes.push({ id: lv1Id, label: lv1.name, data: { level: 'lv1', 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)), }); edges.push({ id: `root-${lv1Id}`, source: 'root', target: lv1Id, size: 2, }); // 二级节点 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, 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)), }); edges.push({ id: `${lv1Id}-${lv2Id}`, source: lv1Id, target: lv2Id, size: 1.5, }); // 三级节点 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, 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)), }); edges.push({ id: `${lv2Id}-${lv3Id}`, source: lv2Id, target: lv3Id, size: 1, }); // 叶子概念 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); // 避免重复添加节点 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, }); }); } }); } // 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); 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, }); }); } }); } }); return { nodes, edges }; }, [hierarchy, priceData, displayLevel]); // 节点点击处理 const handleNodeClick = useCallback((node) => { setSelectedNode(node); if (node.data?.level === 'concept') { const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(node.label)}.html`; window.open(htmlPath, '_blank'); } logger.info('ForceGraphView', '节点点击', { level: node.data?.level, name: node.label }); }, []); // 刷新数据 const handleRefresh = useCallback(() => { fetchHierarchyPrice(); }, [fetchHierarchyPrice]); // 全屏切换 const toggleFullscreen = useCallback(() => { setIsFullscreen(prev => !prev); }, []); // 获取容器高度 const containerHeight = useMemo(() => { if (isFullscreen) return '100vh'; return isMobile ? '500px' : '700px'; }, [isFullscreen, isMobile]); if (loading) { return (