Initial commit
This commit is contained in:
979
src/views/EventDetail/components/TransmissionChainAnalysis.js
Normal file
979
src/views/EventDetail/components/TransmissionChainAnalysis.js
Normal file
@@ -0,0 +1,979 @@
|
||||
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,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon, ViewIcon } from '@chakra-ui/icons';
|
||||
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 [isModalOpen, setIsModalOpen] = useState(false); // 新增:控制弹窗显示
|
||||
const [stats, setStats] = useState({
|
||||
totalNodes: 0,
|
||||
involvedIndustries: 0,
|
||||
relatedCompanies: 0,
|
||||
positiveImpact: 0,
|
||||
negativeImpact: 0,
|
||||
circularEffect: 0
|
||||
});
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const modalBgColor = useColorModeValue('white', 'gray.800');
|
||||
const modalBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 延迟初始化图表,确保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);
|
||||
|
||||
// 打开弹窗
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}
|
||||
// 如果点击的是空白区域,也尝试查找最近的节点
|
||||
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);
|
||||
|
||||
// 打开弹窗
|
||||
setIsModalOpen(true);
|
||||
} 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);
|
||||
|
||||
// 打开弹窗
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 color="green.500">{stats.positiveImpact}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>负向影响</StatLabel>
|
||||
<StatNumber color="red.500">{stats.negativeImpact}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>循环效应</StatLabel>
|
||||
<StatNumber color="purple.500">{stats.circularEffect}</StatNumber>
|
||||
</Stat>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 自定义图例 */}
|
||||
<Box mb={4}>
|
||||
<HStack spacing={4} wrap="wrap">
|
||||
{Object.entries(NODE_STYLES).map(([type, style]) => (
|
||||
<Tag key={type} size="md">
|
||||
<Box w={3} h={3} bg={style.color} borderRadius="sm" mr={2} />
|
||||
{NODE_TYPE_LABELS[type] || type}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 视图切换按钮 */}
|
||||
<Flex mb={4} gap={2}>
|
||||
<Button
|
||||
colorScheme={viewMode === 'graph' ? 'blue' : 'gray'}
|
||||
onClick={() => setViewMode('graph')}
|
||||
size="sm"
|
||||
>
|
||||
力导向图
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={viewMode === 'sankey' ? 'blue' : 'gray'}
|
||||
onClick={() => setViewMode('sankey')}
|
||||
size="sm"
|
||||
>
|
||||
桑基图
|
||||
</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 && (
|
||||
<Box>
|
||||
{/* 提示信息 */}
|
||||
<Alert status="info" mb={4} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
<Icon as={ViewIcon} mr={2} />
|
||||
点击图表中的节点可以查看详细信息
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box
|
||||
h={viewMode === 'sankey' ? "600px" : "700px"}
|
||||
border="1px solid"
|
||||
borderColor="gray.300"
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
bg="white"
|
||||
p={4}
|
||||
ref={containerRef}
|
||||
>
|
||||
{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>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 节点详情弹窗 */}
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="80vh" bg={modalBgColor}>
|
||||
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}>
|
||||
<HStack justify="space-between">
|
||||
<Text>{selectedNode ? '节点详情' : '传导链分析'}</Text>
|
||||
{selectedNode && (
|
||||
<Badge colorScheme="blue">{NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type}</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody overflowY="auto">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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}(AI合成)
|
||||
</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((node, index) => (
|
||||
<ListItem key={index} fontSize="sm">
|
||||
{index === 0 && '🚀 '}
|
||||
{index === transmissionPath.length - 1 && '🎯 '}
|
||||
{index > 0 && index < transmissionPath.length - 1 && '➡️ '}
|
||||
{node.name}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 影响来源 */}
|
||||
{(() => {
|
||||
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})(AI合成)
|
||||
</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}(AI合成)
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme={parent.direction === 'positive' ? 'green' : parent.direction === 'negative' ? 'red' : 'gray'} size="sm">
|
||||
{parent.direction}
|
||||
</Badge>
|
||||
{parent.is_circular && (
|
||||
<Badge colorScheme="purple" size="sm">🔄 循环</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* 影响输出 */}
|
||||
{(() => {
|
||||
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.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}(AI合成)
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme={child.direction === 'positive' ? 'green' : child.direction === 'negative' ? 'red' : 'gray'} size="sm">
|
||||
{child.direction}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack align="center" justify="center" h="300px" spacing={4}>
|
||||
<Text fontSize="lg" color="gray.500">传导链分析</Text>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
点击图表中的节点查看详细信息
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransmissionChainAnalysis;
|
||||
Reference in New Issue
Block a user