import { useState, useEffect, useRef, useCallback } from 'react'; import { fetchCausal } from '../api'; const GROUP_COLORS = { Price: '#3b82f6', Supply: '#10b981', Demand: '#f59e0b', Risk_Geo: '#ef4444', Technical: '#8b5cf6', Alternative: '#06b6d4', Target: '#ff6b6b', }; const GROUP_ZH = { Price: '价格', Supply: '供给', Demand: '需求', Risk_Geo: '风险/地缘', Technical: '技术面', Alternative: '另类数据', Target: '目标', }; const FEAT_ZH = { Brent_spot: 'Brent', vix_lag1: 'VIX', rsi12m: 'RSI', vix_lag2: 'VIX(L2)', mom1m_lag1: '动量', hist_vol_12m: '历史波动率', usd_index: 'USD指数', iron_ore_spot: '铁矿石', rig_count_us_new: '钻井数', ipi_us: '工业产出', natgas_spot_henry: '天然气', nonfarm_us: '非农就业', supply_saudi: '沙特产量', pmi_us_mfg: 'PMI制造业', target_ret_1m: '油价收益率', news_oil_sentiment: '新闻情绪', news_geo_tone: '地缘情绪', news_article_volume: '新闻量', }; /* Simple force layout — runs once on data load */ function layoutNodes(nodes, edges, W, H) { const cx = W / 2, cy = H / 2; // Place target at center const targetIdx = nodes.findIndex(n => n.id === 'target_ret_1m'); if (targetIdx >= 0) { nodes[targetIdx].x = cx; nodes[targetIdx].y = cy; } // Others in a circle around center const others = nodes.filter(n => n.id !== 'target_ret_1m'); const r = Math.min(W, H) * 0.38; others.forEach((n, i) => { const angle = (2 * Math.PI * i) / others.length - Math.PI / 2; n.x = cx + r * Math.cos(angle); n.y = cy + r * Math.sin(angle); }); // Simple force iterations for (let iter = 0; iter < 60; iter++) { // Repulsion for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = 800 / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; if (nodes[i].id !== 'target_ret_1m') { nodes[i].x -= fx; nodes[i].y -= fy; } if (nodes[j].id !== 'target_ret_1m') { nodes[j].x += fx; nodes[j].y += fy; } } } // Attraction for edges edges.forEach(e => { const src = nodes.find(n => n.id === e.source); const tgt = nodes.find(n => n.id === e.target); if (!src || !tgt) return; const dx = tgt.x - src.x; const dy = tgt.y - src.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (dist - 120) * 0.01 * (e.strength || 0.5); const fx = (dx / dist) * force; const fy = (dy / dist) * force; if (src.id !== 'target_ret_1m') { src.x += fx; src.y += fy; } if (tgt.id !== 'target_ret_1m') { tgt.x += fx; tgt.y += fy; } }); // Bounds nodes.forEach(n => { n.x = Math.max(55, Math.min(W - 55, n.x)); n.y = Math.max(30, Math.min(H - 30, n.y)); }); } return nodes; } export default function CausalGraph() { const [data, setData] = useState(null); const [hovered, setHovered] = useState(null); const W = 680, H = 440; useEffect(() => { fetchCausal().then(d => { if (!d || !d.network) return; // Build nodes const nodeMap = {}; // Target node nodeMap['target_ret_1m'] = { id: 'target_ret_1m', group: 'Target', label: '油价收益率', size: 22 }; // Feature nodes from ranking (d.ranking || []).forEach(r => { nodeMap[r.feature] = { id: r.feature, group: r.group, label: FEAT_ZH[r.feature] || r.feature, size: 10 + (r.causal_strength || 0) * 3, strength: r.causal_strength, pValue: r.granger_p, isCausal: r.is_causal === 'True', }; }); // Edges: feature → target const edges = []; (d.network.feature_to_target || []).forEach(e => { if (!nodeMap[e.cause]) return; edges.push({ source: e.cause, target: 'target_ret_1m', pValue: e.p_value, significant: e.significant === 'True', lag: e.best_lag, type: 'to_target', strength: e.significant === 'True' ? 1 : 0.3, }); }); // Edges: inter-feature (only significant ones, p < 0.05) (d.network.inter_feature || []).forEach(e => { if (!nodeMap[e.cause] || !nodeMap[e.effect]) return; if (e.p_value > 0.05) return; edges.push({ source: e.cause, target: e.effect, pValue: e.p_value, significant: true, lag: e.best_lag, type: 'inter', strength: 0.5, }); }); const nodes = Object.values(nodeMap); layoutNodes(nodes, edges, W, H); setData({ nodes, edges, groupStrength: d.group_strength }); }).catch(() => {}); }, []); if (!data) return null; const { nodes, edges, groupStrength } = data; const getNode = id => nodes.find(n => n.id === id); return (
节点大小 = 因果强度 · 实线 = 显著因果关系(p<0.05) · 虚线 = 非显著 · 箭头方向 = 因果方向