Files
vf_react/src/views/EventDetail/components/TransmissionChainAnalysis.js
2025-10-11 12:10:00 +08:00

1345 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useRef } from 'react';
import {
Box,
Button,
Flex,
Spinner,
Alert,
AlertIcon,
Text,
Stat,
StatLabel,
StatNumber,
HStack,
VStack,
Tag,
Badge,
List,
ListItem,
Divider,
CloseButton,
Grid
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { eventService } from '../../../services/eventService';
// 节点样式配置 - 完全复刻Flask版本
const NODE_STYLES = {
'event': { color: '#ff4757', symbol: 'diamond', size: 'large' },
'industry': { color: '#00d2d3', symbol: 'rect', size: 'medium' },
'company': { color: '#54a0ff', symbol: 'circle', size: 'medium' },
'policy': { color: '#10ac84', symbol: 'triangle', size: 'medium' },
'technology': { color: '#ee5a6f', symbol: 'roundRect', size: 'medium' },
'market': { color: '#ffd93d', symbol: 'diamond', size: 'medium' },
'other': { color: '#a4b0be', symbol: 'circle', size: 'small' }
};
// 影响方向颜色映射 - 完全复刻Flask版本
const IMPACT_COLORS = {
'positive': '#52c41a',
'negative': '#ff4d4f',
'neutral': '#999',
'mixed': '#faad14'
};
// 节点类型标签映射
const NODE_TYPE_LABELS = {
'event': '事件',
'industry': '行业',
'company': '公司',
'policy': '政策',
'technology': '技术',
'market': '市场',
'other': '其他'
};
// 过滤孤立节点 - 完全复刻Flask版本
function filterIsolatedNodes(nodes, edges) {
console.log('开始过滤孤立节点');
console.log('输入节点:', nodes);
console.log('输入边:', edges);
if (!nodes || !edges) {
console.log('节点或边数据为空');
return [];
}
const connectedNodeIds = new Set();
edges.forEach(edge => {
console.log('处理边:', edge, '从', edge.source, '到', edge.target);
connectedNodeIds.add(String(edge.source));
connectedNodeIds.add(String(edge.target));
});
console.log('连接的节点ID集合:', connectedNodeIds);
// 如果图中只有一个节点且是主事件,也显示它
const mainEventNode = nodes.find(n => n.extra?.is_main_event);
console.log('主事件节点:', mainEventNode);
if (mainEventNode) {
connectedNodeIds.add(String(mainEventNode.id));
console.log('添加主事件节点ID:', String(mainEventNode.id));
}
const filteredNodes = nodes.filter(node => {
const shouldKeep = connectedNodeIds.has(String(node.id));
console.log(`节点 ${node.name}(${node.id}[${String(node.id)}]): ${shouldKeep ? '保留' : '过滤'}`);
return shouldKeep;
});
console.log('过滤后的节点:', filteredNodes);
return filteredNodes;
}
// 计算节点连接数
function calculateNodeConnections(nodeId, edges) {
return edges.filter(edge =>
String(edge.source) === String(nodeId) || String(edge.target) === String(nodeId)
).length;
}
// 计算节点大小 - 基于连接数,增大整体尺寸方便点击
function calculateNodeSize(connectionCount, isMainEvent = false) {
const baseSize = 35; // 增加基础大小
const maxSize = 70; // 增加最大大小
const minSize = 25; // 增加最小大小
// 主事件节点稍大一些
if (isMainEvent) {
return Math.min(maxSize, baseSize + 20);
}
// 基于连接数计算大小,使用对数函数避免差距过大
const sizeMultiplier = Math.log(connectionCount + 1) * 6;
return Math.min(maxSize, Math.max(minSize, baseSize + sizeMultiplier));
}
// 计算边的宽度 - 基于强度,但差距不要过大
function calculateEdgeWidth(strength) {
const minWidth = 1;
const maxWidth = 4;
if (typeof strength === 'number') {
const normalizedStrength = strength / 100; // 假设强度是0-100
return minWidth + (maxWidth - minWidth) * normalizedStrength;
}
// 如果是字符串类型的强度
switch(strength) {
case 'strong': return 3;
case 'medium': return 2;
case 'weak': return 1;
default: return 2;
}
}
// 力导向图配置 - 完全复刻Flask版本
function getGraphOption(data) {
console.log('getGraphOption 被调用,输入数据:', data);
if (!data || !data.nodes || !data.edges || data.nodes.length === 0) {
console.log('数据为空或无效');
return {
title: { text: '暂无传导链数据', left: 'center', top: 'center' },
graphic: { type: 'text', left: 'center', top: '60%', style: { text: '当前事件暂无传导链分析数据', fontSize: 14 } }
};
}
console.log('原始节点数:', data.nodes.length);
console.log('原始边数:', data.edges.length);
const filteredNodes = filterIsolatedNodes(data.nodes, data.edges);
console.log('过滤后节点数:', filteredNodes.length);
console.log('过滤后的节点:', filteredNodes);
// 进一步过滤:不显示事件类型的节点
const nonEventNodes = filteredNodes.filter(node => node.extra?.node_type !== 'event');
console.log('排除事件节点后:', nonEventNodes.length);
if (nonEventNodes.length === 0) {
console.log('过滤后没有有效节点');
return {
title: { text: '暂无有效节点数据', left: 'center', top: 'center' },
graphic: { type: 'text', left: 'center', top: '60%', style: { text: '当前事件的传导链节点均为孤立节点', fontSize: 14 } }
};
}
// 生成节点类别(排除事件类型)
const categories = [...new Set(nonEventNodes.map(n => n.extra?.node_type || 'other'))].map(type => ({
name: NODE_TYPE_LABELS[type] || type,
itemStyle: { color: NODE_STYLES[type]?.color || NODE_STYLES.other.color }
}));
console.log('节点类别:', categories);
// 构建图表节点数据 - 完全复刻Flask版本样式排除事件节点
const chartNodes = nonEventNodes.map(node => {
const nodeType = node.extra?.node_type || 'other';
const nodeStyle = NODE_STYLES[nodeType] || NODE_STYLES['other'];
const connectionCount = calculateNodeConnections(node.id, data.edges);
console.log(`节点 ${node.name} (${node.id}): 类型=${nodeType}, 连接数=${connectionCount}`);
return {
id: String(node.id),
name: node.name,
value: node.value,
symbol: nodeStyle.symbol,
symbolSize: calculateNodeSize(connectionCount, node.extra?.is_main_event),
category: NODE_TYPE_LABELS[nodeType] || nodeType,
itemStyle: {
color: nodeStyle.color,
borderColor: node.extra?.is_main_event ? '#ffd700' : '#fff',
borderWidth: node.extra?.is_main_event ? 3 : 1,
shadowBlur: 5,
shadowColor: 'rgba(0,0,0,0.2)'
},
label: {
show: true,
position: 'right',
formatter: '{b}',
fontSize: 12,
fontWeight: 'bold',
padding: [5, 8], // 增加标签内边距,扩大点击区域
backgroundColor: 'rgba(255,255,255,0.8)',
borderRadius: 4,
borderColor: '#ddd',
borderWidth: 1
},
emphasis: {
focus: 'none', // 减少高亮敏感性,但保持点击功能
label: {
show: true,
fontSize: 13,
fontWeight: 'bold'
},
itemStyle: {
borderWidth: 3,
shadowBlur: 10
}
},
// 扩大节点的实际点击区域
select: {
itemStyle: {
borderColor: '#007bff',
borderWidth: 3
}
},
extra: node.extra // 包含所有额外信息
};
});
// 构建图表边数据 - 完全复刻Flask版本样式
const chartEdges = data.edges.map(edge => ({
source: String(edge.source),
target: String(edge.target),
value: edge.value,
lineStyle: {
color: IMPACT_COLORS[edge.extra?.direction] || IMPACT_COLORS.neutral,
width: calculateEdgeWidth(edge.extra?.strength || 50),
curveness: edge.extra?.is_circular ? 0.3 : 0,
type: edge.extra?.is_circular ? 'dashed' : 'solid', // 关键:循环边用虚线
},
label: {
show: false // 通常边的标签会很乱,默认关闭
},
symbol: ['none', 'arrow'],
symbolSize: [0, 10],
extra: edge.extra // 包含所有额外信息
}));
// 完全复刻Flask版本的图表选项
return {
tooltip: {
trigger: 'item',
formatter: (params) => {
if (params.dataType === 'node') {
const { name, extra } = params.data;
const connectionCount = calculateNodeConnections(params.data.id, data.edges);
return `<div style="text-align: left;">` +
`<b style="font-size: 14px;">${name}</b><br/>` +
`类型: ${NODE_TYPE_LABELS[extra?.node_type] || extra?.node_type}<br/>` +
`重要性: ${extra?.importance_score}<br/>` +
`连接数: ${connectionCount}` +
`${extra?.is_main_event ? '<br/><span style="color: #ffd700;">★ 主事件</span>' : ''}` +
`<br/><span style="color: #007bff; font-weight: bold;">🖱️ 点击查看详细信息</span>` +
`</div>`;
}
if (params.dataType === 'edge') {
const { source, target, extra } = params.data;
const sourceNode = data.nodes.find(n => String(n.id) === String(source));
const targetNode = data.nodes.find(n => String(n.id) === String(target));
if (!sourceNode || !targetNode) return '加载中...';
return `<div style="text-align: left;">` +
`<b>${sourceNode.name}${targetNode.name}</b><br/>` +
`类型: ${extra?.transmission_type}<br/>` +
`方向: ${extra?.direction}<br/>` +
`强度: ${extra?.strength}` +
`${extra?.is_circular ? '<br/>🔄 循环效应' : ''}` +
`</div>`;
}
return params.name;
}
},
// 隐藏ECharts默认图例使用自定义图例
legend: {
show: false
},
series: [{
type: 'graph',
layout: 'force',
data: chartNodes,
links: chartEdges,
categories: categories,
roam: true,
draggable: true, // 启用节点拖拽
focusNodeAdjacency: false, // 降低敏感性
force: {
repulsion: 250, // 增加节点间距,给点击留更多空间
edgeLength: [100, 180], // 增加边长,扩大节点周围空间
gravity: 0.05, // 降低重力
layoutAnimation: false, // 关闭布局动画降低敏感性
friction: 0.6 // 增加摩擦力,减少移动
},
emphasis: {
focus: 'none', // 降低高亮敏感性
scale: 1.1 // 轻微放大而不是强烈高亮
},
lineStyle: {
opacity: 0.6,
curveness: 0.1
},
label: {
position: 'right',
formatter: '{b}',
show: true,
distance: 10, // 标签距离节点更远一些
fontSize: 12
},
// 增加整体的点击敏感性
silent: false,
triggerLineEvent: true,
triggerEvent: true
}]
};
}
// 桑基图配置
function getSankeyOption(data) {
if (!data || !data.nodes || !data.links) {
return { title: { text: '暂无桑基图数据', left: 'center', top: 'center' } };
}
return {
title: { text: '事件影响力传导流向', left: 'center', top: 10 },
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
formatter: (params) => {
if (params.dataType === 'node') {
return `<b>${params.name}</b><br/>类型: ${params.data.type || 'N/A'}<br/>层级: ${params.data.level || 'N/A'}<br/>点击查看详情`;
}
return params.name;
}
},
series: [{
type: 'sankey',
layout: 'none',
emphasis: { focus: 'adjacency' },
nodeAlign: 'justify',
layoutIterations: 0,
data: data.nodes.map(node => ({
name: node.name,
type: node.type,
level: node.level,
itemStyle: {
color: node.color,
borderColor: node.level === 0 ? '#ffd700' : 'transparent',
borderWidth: node.level === 0 ? 3 : 0
}
})),
links: data.links.map(link => ({
source: data.nodes[link.source]?.name,
target: data.nodes[link.target]?.name,
value: link.value,
lineStyle: { color: 'source', opacity: 0.6, curveness: 0.5 }
})),
label: { color: 'rgba(0,0,0,0.7)', fontSize: 12 }
}]
};
}
const TransmissionChainAnalysis = ({ eventId }) => {
// 状态管理
const [graphData, setGraphData] = useState(null);
const [sankeyData, setSankeyData] = useState(null);
const [selectedNode, setSelectedNode] = useState(null);
const [nodeDetail, setNodeDetail] = useState(null); // 新增存储API获取的详细节点信息
const [transmissionPath, setTransmissionPath] = useState([]);
const [viewMode, setViewMode] = useState('graph');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [chartReady, setChartReady] = useState(false);
const [stats, setStats] = useState({
totalNodes: 0,
involvedIndustries: 0,
relatedCompanies: 0,
positiveImpact: 0,
negativeImpact: 0,
circularEffect: 0
});
const containerRef = useRef(null);
// 延迟初始化图表确保DOM容器准备好
useEffect(() => {
const timer = setTimeout(() => {
setChartReady(true);
}, 100);
return () => clearTimeout(timer);
}, []);
// 计算统计信息
useEffect(() => {
if (graphData && graphData.nodes) {
setStats({
totalNodes: graphData.nodes.length,
involvedIndustries: graphData.nodes.filter(n => n.extra?.node_type === 'industry').length,
relatedCompanies: graphData.nodes.filter(n => n.extra?.node_type === 'company').length,
positiveImpact: graphData.edges?.filter(e => e.extra?.direction === 'positive').length || 0,
negativeImpact: graphData.edges?.filter(e => e.extra?.direction === 'negative').length || 0,
circularEffect: graphData.edges?.filter(e => e.extra?.is_circular).length || 0
});
}
}, [graphData]);
// 加载数据
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
console.log('开始加载传导链数据eventId:', eventId);
const [graphRes, sankeyRes] = await Promise.all([
eventService.getTransmissionChainAnalysis(eventId),
eventService.getSankeyData(eventId)
]);
console.log('传导链数据API响应:', graphRes);
console.log('桑基图数据API响应:', sankeyRes);
if (graphRes.success && graphRes.data) {
console.log('传导链节点数据:', graphRes.data.nodes);
console.log('传导链边数据:', graphRes.data.edges);
setGraphData(graphRes.data);
} else {
console.log('传导链数据加载失败:', graphRes);
setGraphData(null);
}
if (sankeyRes.success && sankeyRes.data) {
console.log('桑基图数据:', sankeyRes.data);
setSankeyData(sankeyRes.data);
} else {
console.log('桑基图数据加载失败:', sankeyRes);
setSankeyData(null);
}
} catch (e) {
console.error('传导链数据加载异常:', e);
setError('加载传导链数据失败');
} finally {
setLoading(false);
}
}
if (eventId) {
fetchData();
}
}, [eventId]);
// BFS路径查找 - 完全复刻Flask版本
function findPath(nodes, edges, fromId, toId) {
const adj = {};
edges.forEach(e => {
if (!adj[e.source]) adj[e.source] = [];
adj[e.source].push(e.target);
});
const queue = [[fromId, [nodes.find(n => String(n.id) === String(fromId))]]];
const visited = new Set([fromId]);
while (queue.length > 0) {
const [current, path] = queue.shift();
if (String(current) === String(toId)) {
return path;
}
(adj[current] || []).forEach(next => {
if (!visited.has(next)) {
visited.add(next);
const nextNode = nodes.find(n => String(n.id) === String(next));
if (nextNode) {
queue.push([next, [...path, nextNode]]);
}
}
});
}
return [];
}
// 获取节点详情 - 完全复刻Flask版本API调用
async function getChainNodeDetail(nodeId) {
try {
const response = await fetch(`/api/events/${eventId}/chain-node/${nodeId}`);
const result = await response.json();
if (result.success) {
return result.data;
} else {
console.error('获取节点详情失败:', result.message);
return null;
}
} catch (error) {
console.error('API调用异常:', error);
return null;
}
}
// 力导向图节点点击事件
const handleGraphNodeClick = async (params) => {
console.log('点击事件详情:', params);
// 处理节点点击(包括节点本体和标签)
if ((params.dataType === 'node' || params.componentType === 'series') && params.data && params.data.id) {
console.log('点击图表节点:', params.data.id, 'dataType:', params.dataType, 'componentType:', params.componentType);
// 获取基本节点信息
const clickedNode = graphData.nodes.find(n => String(n.id) === String(params.data.id));
if (clickedNode) {
setSelectedNode(clickedNode);
// 计算传导路径
const mainEventNode = graphData?.nodes?.find(n => n.extra?.is_main_event);
if (mainEventNode && String(clickedNode.id) !== String(mainEventNode.id)) {
const path = findPath(graphData.nodes, graphData.edges, mainEventNode.id, clickedNode.id);
setTransmissionPath(path);
} else {
setTransmissionPath([]);
}
// 获取详细节点信息包括parents和children
console.log('开始获取节点详情节点ID:', params.data.id);
const detail = await getChainNodeDetail(params.data.id);
console.log('获取到的节点详情:', detail);
setNodeDetail(detail);
}
}
// 如果点击的是空白区域,也尝试查找最近的节点
else if (params.componentType === 'series' && !params.data) {
console.log('点击了图表空白区域');
// 这里可以添加点击空白区域的处理逻辑
}
};
// 桑基图节点点击事件
const handleSankeyNodeClick = async (params) => {
if (params.dataType === 'node' && params.data && params.data.name) {
console.log('点击桑基图节点:', params.data.name);
// 通过名称在原始数据中查找对应的节点
if (graphData && graphData.nodes) {
const clickedNode = graphData.nodes.find(n => n.name === params.data.name);
if (clickedNode) {
console.log('找到对应节点:', clickedNode);
setSelectedNode(clickedNode);
// 计算传导路径
const mainEventNode = graphData?.nodes?.find(n => n.extra?.is_main_event);
if (mainEventNode && String(clickedNode.id) !== String(mainEventNode.id)) {
const path = findPath(graphData.nodes, graphData.edges, mainEventNode.id, clickedNode.id);
setTransmissionPath(path);
} else {
setTransmissionPath([]);
}
// 获取详细节点信息包括parents和children
console.log('开始获取桑基图节点详情节点ID:', clickedNode.id);
const detail = await getChainNodeDetail(clickedNode.id);
console.log('获取到的桑基图节点详情:', detail);
setNodeDetail(detail);
} else {
console.log('未找到对应的节点数据');
// 创建一个临时节点信息用于显示
const tempNode = {
id: params.data.name,
name: params.data.name,
extra: {
node_type: params.data.type,
description: `桑基图节点 - ${params.data.name}`,
importance_score: 'N/A'
}
};
setSelectedNode(tempNode);
setTransmissionPath([]);
setNodeDetail(null);
}
}
}
};
return (
<Box p={6}>
{/* 统计信息条 */}
<Box mb={4}>
<HStack spacing={6} wrap="wrap">
<Stat>
<StatLabel>节点总数</StatLabel>
<StatNumber>{stats.totalNodes}</StatNumber>
</Stat>
<Stat>
<StatLabel>涉及行业</StatLabel>
<StatNumber>{stats.involvedIndustries}</StatNumber>
</Stat>
<Stat>
<StatLabel>相关公司</StatLabel>
<StatNumber>{stats.relatedCompanies}</StatNumber>
</Stat>
<Stat>
<StatLabel>正面影响</StatLabel>
<StatNumber>{stats.positiveImpact}</StatNumber>
</Stat>
<Stat>
<StatLabel>负面影响</StatLabel>
<StatNumber>{stats.negativeImpact}</StatNumber>
</Stat>
<Stat>
<StatLabel>循环效应</StatLabel>
<StatNumber>{stats.circularEffect}</StatNumber>
</Stat>
</HStack>
</Box>
{/* 自定义图例 - 完全复刻Flask版本 */}
{!loading && !error && ((viewMode === 'graph' && graphData) || (viewMode === 'sankey' && sankeyData)) && (
<Box
mb={4}
p={3}
bg="gray.50"
borderRadius="md"
border="1px solid"
borderColor="gray.200"
fontSize="sm"
>
<Flex direction="column" gap={3}>
{/* 根据视图模式显示不同图例 */}
{viewMode === 'graph' ? (
<>
{/* 力导向图图例(不显示事件) */}
{/* 节点类型图例 */}
<Flex align="center" wrap="wrap" gap={4}>
<Text fontWeight="bold" mr={2}>节点类型:</Text>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#00d2d3"
borderRadius="4px"
mr={2}
/>
<Text fontSize="sm">行业</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#54a0ff"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">公司</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="0"
h="0"
borderLeft="8px solid transparent"
borderRight="8px solid transparent"
borderBottom="16px solid #10ac84"
mr={2}
/>
<Text fontSize="sm">政策</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#ee5a6f"
borderRadius="4px"
mr={2}
/>
<Text fontSize="sm">技术</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="12px"
h="12px"
bg="#ffd93d"
borderRadius="2px"
transform="rotate(45deg)"
mr={3}
/>
<Text fontSize="sm">市场</Text>
</Flex>
</Flex>
{/* 影响方向图例 */}
<Flex align="center" wrap="wrap" gap={4}>
<Text fontWeight="bold" mr={2}>影响方向:</Text>
<Flex align="center" mr={4}>
<Box
w="20px"
h="2px"
bg="#52c41a"
mr={2}
/>
<Text fontSize="sm">正向</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="20px"
h="2px"
bg="#ff4d4f"
mr={2}
/>
<Text fontSize="sm">负向</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="20px"
h="2px"
bg="#999"
mr={2}
/>
<Text fontSize="sm">中性</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="20px"
h="2px"
bg="#faad14"
mr={2}
/>
<Text fontSize="sm">混合</Text>
</Flex>
</Flex>
{/* 路径类型图例 */}
<Flex align="center" wrap="wrap" gap={4}>
<Text fontWeight="bold" mr={2}>路径类型:</Text>
<Flex align="center" mr={4}>
<Box
w="20px"
h="2px"
bg="#666"
mr={2}
/>
<Text fontSize="sm">直接影响</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="20px"
h="2px"
backgroundImage="repeating-linear-gradient(to right, #666 0, #666 4px, transparent 4px, transparent 8px)"
mr={2}
/>
<Text fontSize="sm">循环效应</Text>
</Flex>
<Flex align="center" mr={4}>
<Text fontSize="sm" mr={1}></Text>
<Text fontSize="sm">影响方向</Text>
</Flex>
</Flex>
</>
) : (
<>
{/* 桑基图图例 */}
<Flex align="center" wrap="wrap" gap={4}>
<Text fontWeight="bold" mr={2}>节点类型:</Text>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#ff4757"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">事件</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#10ac84"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">政策</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#ee5a6f"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">技术</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#00d2d3"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">行业</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#54a0ff"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">公司</Text>
</Flex>
<Flex align="center" mr={4}>
<Box
w="16px"
h="16px"
bg="#ffd93d"
borderRadius="50%"
mr={2}
/>
<Text fontSize="sm">产品</Text>
</Flex>
</Flex>
<Flex align="center">
<Text fontWeight="bold" mr={2}>说明:</Text>
<Text fontSize="sm" color="gray.600">流的宽度表示影响力大小颜色继承源节点类型</Text>
</Flex>
</>
)}
</Flex>
</Box>
)}
{/* 控制按钮 */}
<Flex mb={4} gap={2} align="center">
<Button
size="sm"
colorScheme={viewMode === 'graph' ? 'blue' : 'gray'}
onClick={() => setViewMode('graph')}
>
力导向图
</Button>
<Button
size="sm"
colorScheme={viewMode === 'sankey' ? 'blue' : 'gray'}
onClick={() => setViewMode('sankey')}
>
桑基图
</Button>
</Flex>
{loading && (
<Flex justify="center" align="center" h="400px">
<Spinner size="xl" />
</Flex>
)}
{error && (
<Alert status="error" mb={4}>
<AlertIcon />
{error}
</Alert>
)}
{!loading && !error && (
<Grid
templateColumns="350px 1fr"
gap={4}
h="800px"
>
{/* 左侧节点详情面板 - 始终显示 */}
<Box
bg="white"
border="1px solid"
borderColor="gray.300"
borderRadius="lg"
boxShadow="md"
overflowY="auto"
h="100%"
>
{/* 详情头部 */}
<Flex
justify="space-between"
align="center"
p={4}
borderBottom="1px solid"
borderColor="gray.200"
bg="gray.50"
borderTopRadius="lg"
>
<Text fontWeight="bold" fontSize="lg" color="gray.800">
{selectedNode ? '节点详情' : '传导链分析'}
</Text>
{selectedNode && (
<CloseButton size="sm" onClick={() => {
setSelectedNode(null);
setNodeDetail(null);
}} />
)}
</Flex>
{/* 详情内容 */}
<Box p={4}>
{selectedNode ? (
<VStack align="stretch" spacing={4}>
{/* 节点基本信息 */}
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">基本信息</Text>
<VStack align="stretch" spacing={2}>
{/* 节点名称和主事件标记 */}
<HStack align="center">
<Text fontSize="sm"><strong>名称:</strong> {selectedNode.name}</Text>
{selectedNode.extra?.is_main_event && (
<Badge colorScheme="red" variant="solid" size="sm">主事件</Badge>
)}
</HStack>
<Text fontSize="sm"><strong>类型:</strong> {NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type}</Text>
<Text fontSize="sm"><strong>重要性评分:</strong> {selectedNode.extra?.importance_score || 'N/A'}</Text>
{/* 股票代码 */}
{selectedNode.extra?.stock_code && (
<Text fontSize="sm"><strong>股票代码:</strong>
<Badge colorScheme="cyan" ml={2}>{selectedNode.extra.stock_code}</Badge>
</Text>
)}
{/* 连接统计 - 使用API数据或计算数据 */}
{nodeDetail ? (
<HStack spacing={4}>
<Badge colorScheme="gray">
总连接: {nodeDetail.node.total_connections || 0}
</Badge>
<Badge colorScheme="green">
来源: {nodeDetail.node.incoming_connections || 0}
</Badge>
<Badge colorScheme="orange">
目标: {nodeDetail.node.outgoing_connections || 0}
</Badge>
</HStack>
) : (
<HStack spacing={4}>
<Badge colorScheme="gray">
总连接: {graphData ? graphData.edges.filter(e =>
String(e.source) === String(selectedNode.id) || String(e.target) === String(selectedNode.id)
).length : 0}
</Badge>
<Badge colorScheme="green">
来源: {graphData ? graphData.edges.filter(e =>
String(e.target) === String(selectedNode.id)
).length : 0}
</Badge>
<Badge colorScheme="orange">
目标: {graphData ? graphData.edges.filter(e =>
String(e.source) === String(selectedNode.id)
).length : 0}
</Badge>
</HStack>
)}
</VStack>
</Box>
{/* 节点描述 */}
{selectedNode.extra?.description && (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">描述</Text>
<Box
fontSize="sm"
color="gray.600"
borderLeft="3px solid"
borderColor="blue.200"
pl={3}
bg="gray.50"
p={2}
borderRadius="md"
fontStyle="italic"
>
{selectedNode.extra.description}
</Box>
</Box>
)}
{/* 传导路径 */}
{transmissionPath && transmissionPath.length > 0 && (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">传导路径</Text>
<Box bg="gray.50" p={3} borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<List spacing={1}>
{transmissionPath.map((pathNode, index) => (
<ListItem key={pathNode.id} fontSize="sm" display="flex" alignItems="center">
<Tag size="sm" bg="gray.200" color="gray.700" mr={2}>
{index + 1}
</Tag>
{pathNode.name}
{index < transmissionPath.length - 1 && (
<Text color="gray.500" fontSize="sm" fontWeight="bold" mx={2}></Text>
)}
</ListItem>
))}
</List>
</Box>
</Box>
)}
{/* 影响来源 - 使用API数据或图数据 */}
{(() => {
const sourcesFromAPI = nodeDetail && nodeDetail.parents && nodeDetail.parents.length > 0;
const sourcesFromGraph = graphData && graphData.edges.filter(e =>
String(e.target) === String(selectedNode.id)
).length > 0;
if (sourcesFromAPI) {
return (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">
影响来源 ({nodeDetail.parents.length})
</Text>
<List spacing={2}>
{nodeDetail.parents.map((parent, index) => (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="green.300">
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{parent.name}</Text>
{parent.transmission_mechanism && (
<Text fontSize="xs" color="gray.600">
机制: {parent.transmission_mechanism}
</Text>
)}
{parent.transmission_type && (
<Text fontSize="xs" color="gray.600">
类型: {parent.transmission_type}
</Text>
)}
</VStack>
<VStack spacing={1}>
{parent.direction && (
<Badge
size="sm"
colorScheme={
parent.direction === 'positive' ? 'green' :
parent.direction === 'negative' ? 'red' :
parent.direction === 'mixed' ? 'orange' : 'gray'
}
>
{parent.direction}
</Badge>
)}
{parent.strength && (
<Badge size="sm" variant="outline">
{parent.strength}
</Badge>
)}
{parent.is_circular && (
<Badge size="sm" colorScheme="purple">
循环
</Badge>
)}
</VStack>
</HStack>
</ListItem>
))}
</List>
</Box>
);
} else if (sourcesFromGraph) {
const sourceEdges = graphData.edges.filter(e => String(e.target) === String(selectedNode.id));
return (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">
影响来源 ({sourceEdges.length})
</Text>
<List spacing={2}>
{sourceEdges.map((edge, index) => {
const sourceNode = graphData.nodes.find(n => String(n.id) === String(edge.source));
return sourceNode ? (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="green.300">
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{sourceNode.name}</Text>
{edge.extra?.transmission_mechanism && (
<Text fontSize="xs" color="gray.600">
机制: {edge.extra.transmission_mechanism}
</Text>
)}
{edge.extra?.transmission_type && (
<Text fontSize="xs" color="gray.600">
类型: {edge.extra.transmission_type}
</Text>
)}
</VStack>
<VStack spacing={1}>
{edge.extra?.direction && (
<Badge
size="sm"
colorScheme={
edge.extra.direction === 'positive' ? 'green' :
edge.extra.direction === 'negative' ? 'red' :
edge.extra.direction === 'mixed' ? 'orange' : 'gray'
}
>
{edge.extra.direction}
</Badge>
)}
{edge.extra?.strength && (
<Badge size="sm" variant="outline">
{edge.extra.strength}
</Badge>
)}
{edge.extra?.is_circular && (
<Badge size="sm" colorScheme="purple">
循环
</Badge>
)}
</VStack>
</HStack>
</ListItem>
) : null;
})}
</List>
</Box>
);
}
return null;
})()}
{/* 影响目标 - 使用API数据或图数据 */}
{(() => {
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0;
const targetsFromGraph = graphData && graphData.edges.filter(e =>
String(e.source) === String(selectedNode.id)
).length > 0;
if (targetsFromAPI) {
return (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">
影响目标 ({nodeDetail.children.length})
</Text>
<List spacing={2}>
{nodeDetail.children.map((child, index) => (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300">
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{child.name}</Text>
{child.transmission_mechanism && (
<Text fontSize="xs" color="gray.600">
机制: {child.transmission_mechanism}
</Text>
)}
{child.transmission_type && (
<Text fontSize="xs" color="gray.600">
类型: {child.transmission_type}
</Text>
)}
</VStack>
<VStack spacing={1}>
{child.direction && (
<Badge
size="sm"
colorScheme={
child.direction === 'positive' ? 'green' :
child.direction === 'negative' ? 'red' :
child.direction === 'mixed' ? 'orange' : 'gray'
}
>
{child.direction}
</Badge>
)}
{child.strength && (
<Badge size="sm" variant="outline">
{child.strength}
</Badge>
)}
{child.is_circular && (
<Badge size="sm" colorScheme="purple">
循环
</Badge>
)}
</VStack>
</HStack>
</ListItem>
))}
</List>
</Box>
);
} else if (targetsFromGraph) {
const targetEdges = graphData.edges.filter(e => String(e.source) === String(selectedNode.id));
return (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">
影响目标 ({targetEdges.length})
</Text>
<List spacing={2}>
{targetEdges.map((edge, index) => {
const targetNode = graphData.nodes.find(n => String(n.id) === String(edge.target));
return targetNode ? (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300">
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{targetNode.name}</Text>
{edge.extra?.transmission_mechanism && (
<Text fontSize="xs" color="gray.600">
机制: {edge.extra.transmission_mechanism}
</Text>
)}
{edge.extra?.transmission_type && (
<Text fontSize="xs" color="gray.600">
类型: {edge.extra.transmission_type}
</Text>
)}
</VStack>
<VStack spacing={1}>
{edge.extra?.direction && (
<Badge
size="sm"
colorScheme={
edge.extra.direction === 'positive' ? 'green' :
edge.extra.direction === 'negative' ? 'red' :
edge.extra.direction === 'mixed' ? 'orange' : 'gray'
}
>
{edge.extra.direction}
</Badge>
)}
{edge.extra?.strength && (
<Badge size="sm" variant="outline">
{edge.extra.strength}
</Badge>
)}
{edge.extra?.is_circular && (
<Badge size="sm" colorScheme="purple">
循环
</Badge>
)}
</VStack>
</HStack>
</ListItem>
) : null;
})}
</List>
</Box>
);
}
return null;
})()}
</VStack>
) : (
<VStack align="center" justify="center" h="400px" spacing={4}>
<Text fontSize="lg" color="gray.500">传导链分析</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
点击右侧图表中的节点<br />查看详细信息
</Text>
<Box p={4} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.400">
<Text fontSize="sm" color="blue.600">
💡 提示此图展示了各要素之间的传导关系节点大小表示重要性边线颜色表示影响方向
</Text>
</Box>
</VStack>
)}
</Box>
</Box>
{/* 右侧图表区域 */}
<Box
ref={containerRef}
w="100%"
h="100%"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
position="relative"
minH="600px"
minW="400px"
>
{chartReady && (
<>
{viewMode === 'graph' ? (
<ReactECharts
option={graphData ? getGraphOption(graphData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleGraphNodeClick,
// 添加更多事件以提高点击敏感性
mouseover: (params) => {
console.log('鼠标悬停:', params);
// 可以在这里添加悬停效果
},
mouseout: (params) => {
// 鼠标离开的处理
}
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
) : (
<ReactECharts
option={sankeyData ? getSankeyOption(sankeyData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleSankeyNodeClick,
// 添加更多事件以提高点击敏感性
mouseover: (params) => {
console.log('桑基图鼠标悬停:', params);
// 可以在这里添加悬停效果
},
mouseout: (params) => {
// 鼠标离开的处理
}
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
)}
</>
)}
</Box>
</Grid>
)}
</Box>
);
};
export default TransmissionChainAnalysis;