update pay ui
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
"react-circular-slider-svg": "^0.1.5",
|
"react-circular-slider-svg": "^0.1.5",
|
||||||
"react-custom-scrollbars-2": "^4.4.0",
|
"react-custom-scrollbars-2": "^4.4.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-force-graph-3d": "^1.29.0",
|
||||||
"react-github-btn": "^1.2.1",
|
"react-github-btn": "^1.2.1",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-input-pin-code": "^1.1.5",
|
"react-input-pin-code": "^1.1.5",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"styled-components": "^5.3.11",
|
"styled-components": "^5.3.11",
|
||||||
"stylis": "^4.0.10",
|
"stylis": "^4.0.10",
|
||||||
"stylis-plugin-rtl": "^2.1.1",
|
"stylis-plugin-rtl": "^2.1.1",
|
||||||
|
"three": "^0.181.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"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,
|
useBreakpointValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
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 { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||||
import { keyframes } from '@emotion/react';
|
import { keyframes } from '@emotion/react';
|
||||||
import ConceptTimelineModal from './ConceptTimelineModal';
|
import ConceptTimelineModal from './ConceptTimelineModal';
|
||||||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||||||
import HierarchyView from './components/HierarchyView';
|
import HierarchyView from './components/HierarchyView';
|
||||||
|
import ForceGraphView from './components/ForceGraphView';
|
||||||
import BreadcrumbNav from './components/BreadcrumbNav';
|
import BreadcrumbNav from './components/BreadcrumbNav';
|
||||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||||
import TradeDatePicker from '@components/TradeDatePicker';
|
import TradeDatePicker from '@components/TradeDatePicker';
|
||||||
@@ -1759,6 +1760,25 @@ const ConceptCenter = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<ButtonGroup size="sm" isAttached variant="outline">
|
<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">
|
<Tooltip label="层级图" placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<FaSitemap />}
|
icon={<FaSitemap />}
|
||||||
@@ -1829,7 +1849,7 @@ const ConceptCenter = () => {
|
|||||||
isDarkMode={true}
|
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">
|
<Box mb={4} p={3} bg="rgba(59, 130, 246, 0.2)" borderRadius="xl" borderLeft="4px solid" borderColor="blue.400">
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={InfoIcon} color="blue.300" />
|
<Icon as={InfoIcon} color="blue.300" />
|
||||||
@@ -1841,8 +1861,15 @@ const ConceptCenter = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 层级图视图 */}
|
{/* 3D 力导向图视图 */}
|
||||||
{viewMode === 'hierarchy' ? (
|
{viewMode === 'force3d' ? (
|
||||||
|
<ForceGraphView
|
||||||
|
apiBaseUrl={API_BASE_URL}
|
||||||
|
onSelectCategory={handleHierarchySelect}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
/>
|
||||||
|
) : /* 层级图视图 */
|
||||||
|
viewMode === 'hierarchy' ? (
|
||||||
<HierarchyView
|
<HierarchyView
|
||||||
apiBaseUrl={API_BASE_URL}
|
apiBaseUrl={API_BASE_URL}
|
||||||
onSelectCategory={handleHierarchySelect}
|
onSelectCategory={handleHierarchySelect}
|
||||||
@@ -1956,7 +1983,7 @@ const ConceptCenter = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Center>
|
</Center>
|
||||||
</>
|
</>
|
||||||
) : viewMode !== 'hierarchy' ? (
|
) : viewMode !== 'hierarchy' && viewMode !== 'force3d' ? (
|
||||||
<Center h="400px">
|
<Center h="400px">
|
||||||
<VStack spacing={6}>
|
<VStack spacing={6}>
|
||||||
<Icon as={FaTags} boxSize={20} color="whiteAlpha.300" />
|
<Icon as={FaTags} boxSize={20} color="whiteAlpha.300" />
|
||||||
|
|||||||
Reference in New Issue
Block a user