849 lines
33 KiB
JavaScript
849 lines
33 KiB
JavaScript
/**
|
||
* 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 (
|
||
<Center h="500px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
|
||
<VStack spacing={4}>
|
||
<Spinner size="xl" color="purple.400" thickness="4px" />
|
||
<Text color="gray.400">正在构建概念关系图...</Text>
|
||
</VStack>
|
||
</Center>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Center h="500px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
|
||
<VStack spacing={4}>
|
||
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
|
||
<Text color="gray.400">加载失败:{error}</Text>
|
||
<Button colorScheme="purple" size="sm" onClick={fetchHierarchy}>
|
||
重试
|
||
</Button>
|
||
</VStack>
|
||
</Center>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
ref={containerRef}
|
||
position={isFullscreen ? 'fixed' : 'relative'}
|
||
top={isFullscreen ? 0 : 'auto'}
|
||
left={isFullscreen ? 0 : 'auto'}
|
||
right={isFullscreen ? 0 : 'auto'}
|
||
bottom={isFullscreen ? 0 : 'auto'}
|
||
zIndex={isFullscreen ? 1000 : 'auto'}
|
||
bg="linear-gradient(135deg, #0F172A 0%, #1E1B4B 50%, #0F172A 100%)"
|
||
borderRadius={isFullscreen ? '0' : '2xl'}
|
||
overflow="hidden"
|
||
border={isFullscreen ? 'none' : '1px solid'}
|
||
borderColor="whiteAlpha.200"
|
||
h={containerHeight}
|
||
>
|
||
{/* 背景装饰 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
opacity={0.1}
|
||
bgImage="radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.3) 0%, transparent 50%),
|
||
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.3) 0%, transparent 50%)"
|
||
pointerEvents="none"
|
||
/>
|
||
|
||
{/* 顶部工具栏 */}
|
||
<Flex
|
||
position="absolute"
|
||
top={4}
|
||
left={4}
|
||
right={4}
|
||
justify="space-between"
|
||
align="flex-start"
|
||
zIndex={10}
|
||
pointerEvents="none"
|
||
>
|
||
{/* 左侧标题和信息 */}
|
||
<VStack align="start" spacing={3} pointerEvents="auto">
|
||
<HStack
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
backdropFilter="blur(12px)"
|
||
px={4}
|
||
py={2}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||
>
|
||
<Icon as={FaProjectDiagram} color="purple.300" />
|
||
<Text color="white" fontWeight="bold" fontSize="sm">
|
||
概念关系图
|
||
</Text>
|
||
<Badge
|
||
bg="purple.500"
|
||
color="white"
|
||
borderRadius="full"
|
||
px={2}
|
||
fontSize="xs"
|
||
>
|
||
{graphData.nodes.length} 节点
|
||
</Badge>
|
||
</HStack>
|
||
|
||
{/* 选中节点信息卡片 */}
|
||
{selectedNode && selectedNode.data?.level !== 'root' && (
|
||
<Box
|
||
bg="rgba(0, 0, 0, 0.85)"
|
||
backdropFilter="blur(12px)"
|
||
px={4}
|
||
py={3}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor={selectedNode.fill || 'purple.500'}
|
||
boxShadow={`0 4px 20px ${getGlowColor(selectedNode.data?.changePct)}`}
|
||
maxW="280px"
|
||
>
|
||
<VStack align="start" spacing={2}>
|
||
<HStack spacing={2}>
|
||
<Badge
|
||
bg={selectedNode.data?.baseColor || 'purple.500'}
|
||
color="white"
|
||
borderRadius="full"
|
||
px={2}
|
||
fontSize="xs"
|
||
>
|
||
{selectedNode.data?.level === 'lv1' ? '一级分类' :
|
||
selectedNode.data?.level === 'lv2' ? '二级分类' :
|
||
selectedNode.data?.level === 'lv3' ? '三级分类' : '概念'}
|
||
</Badge>
|
||
</HStack>
|
||
|
||
<Text color="white" fontWeight="bold" fontSize="md">
|
||
{selectedNode.label}
|
||
</Text>
|
||
|
||
{/* 涨跌幅显示 */}
|
||
{selectedNode.data?.changePct !== undefined && selectedNode.data?.changePct !== null && (
|
||
<HStack
|
||
bg={selectedNode.data.changePct > 0 ? 'rgba(239, 68, 68, 0.2)' : selectedNode.data.changePct < 0 ? 'rgba(34, 197, 94, 0.2)' : 'rgba(100, 116, 139, 0.2)'}
|
||
px={3}
|
||
py={1}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor={selectedNode.data.changePct > 0 ? 'red.400' : selectedNode.data.changePct < 0 ? 'green.400' : 'gray.500'}
|
||
>
|
||
<Icon
|
||
as={selectedNode.data.changePct > 0 ? FaArrowUp : selectedNode.data.changePct < 0 ? FaArrowDown : FaCircle}
|
||
color={selectedNode.data.changePct > 0 ? 'red.400' : selectedNode.data.changePct < 0 ? 'green.400' : 'gray.400'}
|
||
boxSize={3}
|
||
/>
|
||
<Text
|
||
color={selectedNode.data.changePct > 0 ? 'red.300' : selectedNode.data.changePct < 0 ? 'green.300' : 'gray.300'}
|
||
fontWeight="bold"
|
||
fontSize="lg"
|
||
fontFamily="mono"
|
||
>
|
||
{formatChangePercent(selectedNode.data.changePct)}
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
|
||
<HStack spacing={4} color="whiteAlpha.700" fontSize="xs">
|
||
{selectedNode.data?.stockCount && (
|
||
<Text>{selectedNode.data.stockCount} 只股票</Text>
|
||
)}
|
||
{selectedNode.data?.conceptCount && (
|
||
<Text>{selectedNode.data.conceptCount} 个概念</Text>
|
||
)}
|
||
</HStack>
|
||
|
||
{selectedNode.data?.level === 'concept' && (
|
||
<Text color="purple.300" fontSize="xs">
|
||
点击查看详情 →
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</Box>
|
||
)}
|
||
</VStack>
|
||
|
||
{/* 右侧控制按钮 */}
|
||
<VStack spacing={2} pointerEvents="auto">
|
||
<HStack spacing={2}>
|
||
{priceLoading && <Spinner size="sm" color="purple.300" />}
|
||
|
||
<Tooltip label="刷新数据" placement="left">
|
||
<IconButton
|
||
size="sm"
|
||
icon={<FaSync />}
|
||
onClick={handleRefresh}
|
||
isLoading={priceLoading}
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
color="white"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
_hover={{ bg: 'whiteAlpha.200', borderColor: 'purple.400' }}
|
||
aria-label="刷新"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Tooltip label="设置" placement="left">
|
||
<IconButton
|
||
size="sm"
|
||
icon={isSettingsOpen ? <FaChevronUp /> : <FaCog />}
|
||
onClick={onSettingsToggle}
|
||
bg={isSettingsOpen ? 'purple.500' : 'rgba(0, 0, 0, 0.7)'}
|
||
color="white"
|
||
border="1px solid"
|
||
borderColor={isSettingsOpen ? 'purple.400' : 'whiteAlpha.200'}
|
||
_hover={{ bg: isSettingsOpen ? 'purple.400' : 'whiteAlpha.200' }}
|
||
aria-label="设置"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left">
|
||
<IconButton
|
||
size="sm"
|
||
icon={isFullscreen ? <FaCompress /> : <FaExpand />}
|
||
onClick={toggleFullscreen}
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
color="white"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
_hover={{ bg: 'whiteAlpha.200', borderColor: 'purple.400' }}
|
||
aria-label={isFullscreen ? '退出全屏' : '全屏'}
|
||
/>
|
||
</Tooltip>
|
||
</HStack>
|
||
|
||
{/* 设置面板 */}
|
||
<Collapse in={isSettingsOpen}>
|
||
<Box
|
||
bg="rgba(0, 0, 0, 0.85)"
|
||
backdropFilter="blur(12px)"
|
||
p={4}
|
||
borderRadius="xl"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
w="220px"
|
||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
|
||
>
|
||
<VStack spacing={4} align="stretch">
|
||
<FormControl>
|
||
<FormLabel color="whiteAlpha.800" fontSize="xs" mb={1}>
|
||
布局方式
|
||
</FormLabel>
|
||
<Select
|
||
value={layoutType}
|
||
onChange={(e) => setLayoutType(e.target.value)}
|
||
size="sm"
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{ borderColor: 'purple.400' }}
|
||
sx={{ option: { bg: '#1a1a2e', color: 'white' } }}
|
||
>
|
||
<option value="radialOut2d">放射状</option>
|
||
<option value="treeTd2d">树形(上下)</option>
|
||
<option value="treeLr2d">树形(左右)</option>
|
||
<option value="forceDirected2d">力导向</option>
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<FormControl>
|
||
<FormLabel color="whiteAlpha.800" fontSize="xs" mb={1}>
|
||
显示层级
|
||
</FormLabel>
|
||
<Select
|
||
value={displayLevel}
|
||
onChange={(e) => setDisplayLevel(e.target.value)}
|
||
size="sm"
|
||
bg="whiteAlpha.100"
|
||
color="white"
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{ borderColor: 'purple.400' }}
|
||
sx={{ option: { bg: '#1a1a2e', color: 'white' } }}
|
||
>
|
||
<option value="lv1">一级分类</option>
|
||
<option value="lv2">到二级</option>
|
||
<option value="lv3">到三级</option>
|
||
<option value="all">全部(含概念)</option>
|
||
</Select>
|
||
</FormControl>
|
||
</VStack>
|
||
</Box>
|
||
</Collapse>
|
||
</VStack>
|
||
</Flex>
|
||
|
||
{/* 底部图例 */}
|
||
<Flex
|
||
position="absolute"
|
||
bottom={4}
|
||
left={4}
|
||
zIndex={10}
|
||
gap={2}
|
||
flexWrap="wrap"
|
||
pointerEvents="none"
|
||
>
|
||
<HStack
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
backdropFilter="blur(10px)"
|
||
px={3}
|
||
py={2}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
spacing={2}
|
||
>
|
||
<Box w={3} h={3} borderRadius="full" bg="#EF4444" boxShadow="0 0 8px rgba(239, 68, 68, 0.5)" />
|
||
<Text color="whiteAlpha.800" fontSize="xs">涨</Text>
|
||
</HStack>
|
||
<HStack
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
backdropFilter="blur(10px)"
|
||
px={3}
|
||
py={2}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
spacing={2}
|
||
>
|
||
<Box w={3} h={3} borderRadius="full" bg="#22C55E" boxShadow="0 0 8px rgba(34, 197, 94, 0.5)" />
|
||
<Text color="whiteAlpha.800" fontSize="xs">跌</Text>
|
||
</HStack>
|
||
<HStack
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
backdropFilter="blur(10px)"
|
||
px={3}
|
||
py={2}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
spacing={2}
|
||
>
|
||
<Box w={3} h={3} borderRadius="full" bg="#64748B" />
|
||
<Text color="whiteAlpha.800" fontSize="xs">平/无数据</Text>
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 操作提示 */}
|
||
<Box
|
||
position="absolute"
|
||
bottom={4}
|
||
right={4}
|
||
zIndex={10}
|
||
pointerEvents="none"
|
||
>
|
||
<HStack
|
||
bg="rgba(0, 0, 0, 0.7)"
|
||
backdropFilter="blur(10px)"
|
||
px={3}
|
||
py={2}
|
||
borderRadius="full"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.200"
|
||
>
|
||
<Text color="whiteAlpha.600" fontSize="xs">
|
||
滚轮缩放 · 拖拽平移 · 点击节点查看详情
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
|
||
{/* 图表 */}
|
||
<GraphCanvas
|
||
ref={graphRef}
|
||
nodes={graphData.nodes}
|
||
edges={graphData.edges}
|
||
theme={customDarkTheme}
|
||
layoutType={layoutType}
|
||
labelType="all"
|
||
edgeArrowPosition="none"
|
||
draggable
|
||
onNodeClick={handleNodeClick}
|
||
contextMenu={null}
|
||
cameraMode="pan"
|
||
sizingType="centrality"
|
||
/>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default ForceGraphView;
|