update pay ui
This commit is contained in:
@@ -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": {
|
||||
|
||||
918
src/views/Concept/components/ForceGraphView.js
Normal file
918
src/views/Concept/components/ForceGraphView.js
Normal file
@@ -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 (
|
||||
<Center h="400px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="purple.400" thickness="4px" />
|
||||
<Text color="gray.400">正在构建 3D 星图...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="400px" 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="rgba(15, 23, 42, 0.95)"
|
||||
borderRadius={isFullscreen ? '0' : '2xl'}
|
||||
overflow="hidden"
|
||||
border={isFullscreen ? 'none' : '1px solid'}
|
||||
borderColor="whiteAlpha.100"
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<Flex
|
||||
position="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
right={4}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
zIndex={10}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* 左侧信息 */}
|
||||
<VStack align="start" spacing={2} pointerEvents="auto">
|
||||
<HStack
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
backdropFilter="blur(10px)"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
>
|
||||
<Icon as={FaCube} color="purple.300" />
|
||||
<Text color="white" fontWeight="bold" fontSize="sm">
|
||||
3D 概念星图
|
||||
</Text>
|
||||
<Badge colorScheme="purple" variant="solid" borderRadius="full">
|
||||
{graphData.nodes.length} 节点
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 悬停节点信息 */}
|
||||
{hoveredNode && hoveredNode.level !== 'root' && (
|
||||
<Box
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
backdropFilter="blur(10px)"
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor={hoveredNode.color}
|
||||
maxW="300px"
|
||||
>
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack>
|
||||
<Badge
|
||||
bg={hoveredNode.baseColor}
|
||||
color="white"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
fontSize="xs"
|
||||
>
|
||||
{hoveredNode.level === 'lv1' ? '一级' :
|
||||
hoveredNode.level === 'lv2' ? '二级' :
|
||||
hoveredNode.level === 'lv3' ? '三级' : '概念'}
|
||||
</Badge>
|
||||
{hoveredNode.changePct !== undefined && (
|
||||
<Badge
|
||||
bg={hoveredNode.changePct > 0 ? 'red.500' : hoveredNode.changePct < 0 ? 'green.500' : 'gray.500'}
|
||||
color="white"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
fontSize="xs"
|
||||
>
|
||||
{formatChangePercent(hoveredNode.changePct)}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Text color="white" fontWeight="bold" fontSize="md">
|
||||
{hoveredNode.name}
|
||||
</Text>
|
||||
{hoveredNode.stockCount && (
|
||||
<Text color="whiteAlpha.700" fontSize="xs">
|
||||
{hoveredNode.stockCount} 只股票
|
||||
{hoveredNode.conceptCount ? ` · ${hoveredNode.conceptCount} 个概念` : ''}
|
||||
</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.6)"
|
||||
color="white"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label="刷新"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="重置视角" placement="left">
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<FaHome />}
|
||||
onClick={handleResetView}
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
color="white"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label="重置视角"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={isRotating ? '暂停旋转' : '开始旋转'} placement="left">
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={isRotating ? <FaPause /> : <FaPlay />}
|
||||
onClick={() => setIsRotating(!isRotating)}
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
color={isRotating ? 'green.300' : 'white'}
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label={isRotating ? '暂停旋转' : '开始旋转'}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="设置" placement="left">
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={isSettingsOpen ? <FaChevronUp /> : <FaCog />}
|
||||
onClick={onSettingsToggle}
|
||||
bg={isSettingsOpen ? 'purple.500' : 'rgba(0, 0, 0, 0.6)'}
|
||||
color="white"
|
||||
border="1px solid"
|
||||
borderColor="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.6)"
|
||||
color="white"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label={isFullscreen ? '退出全屏' : '全屏'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
{/* 设置面板 */}
|
||||
<Collapse in={isSettingsOpen}>
|
||||
<Box
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
backdropFilter="blur(10px)"
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
w="250px"
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl>
|
||||
<FormLabel color="whiteAlpha.800" fontSize="sm">
|
||||
显示层级
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={displayLevel}
|
||||
onChange={(e) => setDisplayLevel(e.target.value)}
|
||||
size="sm"
|
||||
bg="whiteAlpha.100"
|
||||
color="white"
|
||||
borderColor="whiteAlpha.300"
|
||||
_hover={{ borderColor: 'purple.400' }}
|
||||
>
|
||||
<option value="all" style={{ background: '#1a1a2e' }}>全部层级</option>
|
||||
<option value="lv1" style={{ background: '#1a1a2e' }}>仅一级</option>
|
||||
<option value="lv2" style={{ background: '#1a1a2e' }}>仅二级</option>
|
||||
<option value="lv3" style={{ background: '#1a1a2e' }}>仅三级</option>
|
||||
<option value="concept" style={{ background: '#1a1a2e' }}>仅概念</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel color="whiteAlpha.800" fontSize="sm">
|
||||
节点大小: {nodeSize.toFixed(1)}x
|
||||
</FormLabel>
|
||||
<Slider
|
||||
value={nodeSize}
|
||||
onChange={setNodeSize}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
colorScheme="purple"
|
||||
>
|
||||
<SliderTrack bg="whiteAlpha.200">
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel color="whiteAlpha.800" fontSize="sm">
|
||||
连线透明度: {(linkOpacity * 100).toFixed(0)}%
|
||||
</FormLabel>
|
||||
<Slider
|
||||
value={linkOpacity}
|
||||
onChange={setLinkOpacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
colorScheme="purple"
|
||||
>
|
||||
<SliderTrack bg="whiteAlpha.200">
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel color="whiteAlpha.800" fontSize="sm" mb={0}>
|
||||
显示标签
|
||||
</FormLabel>
|
||||
<Switch
|
||||
isChecked={showLabels}
|
||||
onChange={(e) => setShowLabels(e.target.checked)}
|
||||
colorScheme="purple"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 图例 */}
|
||||
<Flex
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
left={4}
|
||||
zIndex={10}
|
||||
gap={3}
|
||||
flexWrap="wrap"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<HStack
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
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" />
|
||||
<Text color="whiteAlpha.800" fontSize="xs">涨</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
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" />
|
||||
<Text color="whiteAlpha.800" fontSize="xs">跌</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
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.6)"
|
||||
backdropFilter="blur(10px)"
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
spacing={3}
|
||||
>
|
||||
<Text color="whiteAlpha.600" fontSize="xs">
|
||||
🖱️ 左键旋转 · 右键平移 · 滚轮缩放 · 点击聚焦
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 3D Force Graph */}
|
||||
<ForceGraph3D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
height={isFullscreen ? window.innerHeight : (isMobile ? 500 : 700)}
|
||||
width={containerRef.current?.clientWidth || window.innerWidth}
|
||||
backgroundColor="rgba(15, 23, 42, 0)"
|
||||
nodeThreeObject={nodeThreeObject}
|
||||
nodeLabel={showLabels ? (node) => `
|
||||
<div style="
|
||||
background: rgba(0,0,0,0.8);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
border: 1px solid ${node.color};
|
||||
">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${node.name}</div>
|
||||
${node.changePct !== undefined ? `
|
||||
<div style="color: ${node.changePct > 0 ? '#FCA5A5' : node.changePct < 0 ? '#86EFAC' : '#94A3B8'}">
|
||||
${node.changePct > 0 ? '↑' : node.changePct < 0 ? '↓' : ''}
|
||||
${formatChangePercent(node.changePct)}
|
||||
</div>
|
||||
` : ''}
|
||||
${node.stockCount ? `<div style="color: #94A3B8; font-size: 11px;">${node.stockCount} 只股票</div>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
linkColor={(link) => link.color}
|
||||
linkOpacity={linkOpacity}
|
||||
linkWidth={1}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={(node) => setHoveredNode(node)}
|
||||
enableNodeDrag={true}
|
||||
enableNavigationControls={true}
|
||||
showNavInfo={false}
|
||||
d3AlphaDecay={0.02}
|
||||
d3VelocityDecay={0.3}
|
||||
warmupTicks={100}
|
||||
cooldownTicks={200}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForceGraphView;
|
||||
@@ -81,12 +81,13 @@ import {
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock, FaSitemap, FaLayerGroup } from 'react-icons/fa';
|
||||
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock, FaSitemap, FaLayerGroup, FaCube, FaProjectDiagram } from 'react-icons/fa';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import ConceptTimelineModal from './ConceptTimelineModal';
|
||||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||||
import HierarchyView from './components/HierarchyView';
|
||||
import ForceGraphView from './components/ForceGraphView';
|
||||
import BreadcrumbNav from './components/BreadcrumbNav';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
@@ -1759,6 +1760,25 @@ const ConceptCenter = () => {
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<Tooltip label="3D星图" placement="top">
|
||||
<IconButton
|
||||
icon={<FaCube />}
|
||||
onClick={() => {
|
||||
if (viewMode !== 'force3d') {
|
||||
trackViewModeChanged('force3d', viewMode);
|
||||
setViewMode('force3d');
|
||||
}
|
||||
}}
|
||||
bg={viewMode === 'force3d' ? 'purple.500' : 'transparent'}
|
||||
color={viewMode === 'force3d' ? 'white' : 'whiteAlpha.700'}
|
||||
borderColor="whiteAlpha.300"
|
||||
_hover={{
|
||||
bg: viewMode === 'force3d' ? 'purple.400' : 'whiteAlpha.100',
|
||||
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
|
||||
}}
|
||||
aria-label="3D星图"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="层级图" placement="top">
|
||||
<IconButton
|
||||
icon={<FaSitemap />}
|
||||
@@ -1829,7 +1849,7 @@ const ConceptCenter = () => {
|
||||
isDarkMode={true}
|
||||
/>
|
||||
|
||||
{selectedDate && viewMode !== 'hierarchy' && (
|
||||
{selectedDate && viewMode !== 'hierarchy' && viewMode !== 'force3d' && (
|
||||
<Box mb={4} p={3} bg="rgba(59, 130, 246, 0.2)" borderRadius="xl" borderLeft="4px solid" borderColor="blue.400">
|
||||
<HStack>
|
||||
<Icon as={InfoIcon} color="blue.300" />
|
||||
@@ -1841,8 +1861,15 @@ const ConceptCenter = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 层级图视图 */}
|
||||
{viewMode === 'hierarchy' ? (
|
||||
{/* 3D 力导向图视图 */}
|
||||
{viewMode === 'force3d' ? (
|
||||
<ForceGraphView
|
||||
apiBaseUrl={API_BASE_URL}
|
||||
onSelectCategory={handleHierarchySelect}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
) : /* 层级图视图 */
|
||||
viewMode === 'hierarchy' ? (
|
||||
<HierarchyView
|
||||
apiBaseUrl={API_BASE_URL}
|
||||
onSelectCategory={handleHierarchySelect}
|
||||
@@ -1956,7 +1983,7 @@ const ConceptCenter = () => {
|
||||
</HStack>
|
||||
</Center>
|
||||
</>
|
||||
) : viewMode !== 'hierarchy' ? (
|
||||
) : viewMode !== 'hierarchy' && viewMode !== 'force3d' ? (
|
||||
<Center h="400px">
|
||||
<VStack spacing={6}>
|
||||
<Icon as={FaTags} boxSize={20} color="whiteAlpha.300" />
|
||||
|
||||
Reference in New Issue
Block a user