import { useState, useCallback, useEffect, useRef } from "react"; import ForceGraph2D from "react-force-graph-2d"; import { X } from "lucide-react"; const configuredBaseUrl = import.meta.env.VITE_API_URL?.trim(); const BASE_URL = configuredBaseUrl || ""; function getNodeColor(type, riskLevel) { if (riskLevel === "HIGH") return "#ef4444"; if (type === "person") return "#3b82f6"; if (type === "company") return "#8b5cf6"; if (type === "loan") return "#eab308"; return "#7a7a85"; } function getNodeIcon(type) { if (type === "person") return "👤"; if (type === "company") return "🏢"; if (type === "loan") return "💰"; return "●"; } function LegendDot({ color, label, dashed }) { return (
{dashed ? ( ) : (
)} {label}
); } export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, jobId, }) { const [fetchedNodes, setFetchedNodes] = useState(null); const [fetchedEdges, setFetchedEdges] = useState(null); const [loading, setLoading] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [hoverNode, setHoverNode] = useState(null); const graphRef = useRef(); const hasPropsData = propNodes && propNodes.length > 0; const nodes = hasPropsData ? propNodes : fetchedNodes; const edges = hasPropsData ? propEdges : fetchedEdges; useEffect(() => { if (hasPropsData || !jobId) return; let cancelled = false; setLoading(true); fetch(`${BASE_URL}/api/analysis/${encodeURIComponent(jobId)}/entity-graph`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (cancelled || !data) return; if (data.nodes && data.nodes.length > 0) { setFetchedNodes(data.nodes); setFetchedEdges(data.edges || []); } }) .catch(() => {}) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [hasPropsData, jobId]); const hasProbableMatch = (edges || []).some((e) => e.is_probable_match); const hasHighRisk = (nodes || []).some((n) => n.risk_level === "HIGH"); const historicalNodes = (nodes || []).filter( (n) => n.historical_match != null && n.historical_match !== undefined && n.historical_match !== false, ); if (loading) { return (

Loading entity graph…

); } if (!nodes || nodes.length === 0) { return (

No entity relationships detected in uploaded documents.

Entity graph is built from promoter names, related companies, and loan cross-references extracted via NER.

); } const graphData = { nodes: nodes.map((n) => ({ id: n.id, name: n.name, type: n.type, risk_level: n.risk_level, historical_match: n.historical_match, color: getNodeColor(n.type, n.risk_level), })), links: edges.map((e) => ({ source: e.source, target: e.target, relationship: e.relationship, amount_crore: e.amount_crore, is_probable_match: e.is_probable_match, color: e.is_probable_match ? "#eab308" : "rgba(255,255,255,0.08)", })), }; const handleNodeClick = useCallback((node) => { setSelectedNode((prev) => (prev && prev.id === node.id ? null : node)); }, []); function riskBadge(level) { if (level === "HIGH") return "badge-danger"; if (level === "MEDIUM") return "badge-warning"; return "badge-success"; } return (
{/* Alert banners */} {hasProbableMatch && (

⚠ Fuzzy-matched entities detected. Manual verification recommended.

)} {hasHighRisk && (

🚩 Related-party anomaly detected. Possible shell company or fund siphoning risk.

)} {historicalNodes.map((node) => (

⚠ HISTORICAL MATCH DETECTED

Director DIN {node.id} appeared in a previously rejected application. Escalate.

))} {/* Graph + sidebar */}
(n.risk_level === "HIGH" ? 12 : 6)} linkWidth={(link) => (link.is_probable_match ? 1.5 : 2.5)} linkLineDash={(link) => (link.is_probable_match ? [4, 4] : [])} backgroundColor="transparent" width={selectedNode ? 640 : 800} height={500} onNodeClick={handleNodeClick} onNodeHover={(node) => setHoverNode(node || null)} cooldownTicks={80} d3AlphaDecay={0.02} d3VelocityDecay={0.3} nodeCanvasObject={(node, ctx, globalScale) => { if (typeof node.x !== "number" || typeof node.y !== "number") return; const r = node.risk_level === "HIGH" ? 10 : 7; const isHover = hoverNode && hoverNode.id === node.id; const isSel = selectedNode && selectedNode.id === node.id; const color = node.color; // Outer glow ctx.beginPath(); ctx.arc(node.x, node.y, r + (isHover ? 8 : 4), 0, 2 * Math.PI); const grad = ctx.createRadialGradient( node.x, node.y, r, node.x, node.y, r + (isHover ? 14 : 6), ); grad.addColorStop(0, color + (isHover ? "55" : "25")); grad.addColorStop(1, color + "00"); ctx.fillStyle = grad; ctx.fill(); // Ring for selected if (isSel) { ctx.beginPath(); ctx.arc(node.x, node.y, r + 3, 0, 2 * Math.PI); ctx.strokeStyle = "#ffffff55"; ctx.lineWidth = 1.5; ctx.stroke(); } // Main circle ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); const cGrad = ctx.createRadialGradient( node.x - r * 0.3, node.y - r * 0.3, r * 0.1, node.x, node.y, r, ); cGrad.addColorStop(0, color + "ff"); cGrad.addColorStop(1, color + "aa"); ctx.fillStyle = cGrad; ctx.fill(); ctx.strokeStyle = color + "88"; ctx.lineWidth = 1; ctx.stroke(); // High-risk outer ring if (node.risk_level === "HIGH") { ctx.beginPath(); ctx.arc(node.x, node.y, r + 5, 0, 2 * Math.PI); ctx.strokeStyle = "rgba(239,68,68,0.25)"; ctx.lineWidth = 1.5; ctx.stroke(); } // Label const labelSize = isHover ? 12 : 10; ctx.font = `${isHover ? "600" : "500"} ${labelSize / globalScale}px Inter, sans-serif`; ctx.fillStyle = isHover ? "#ffffff" : "#b8b8bfcc"; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText(node.name, node.x, node.y + r + 4 / globalScale); }} nodePointerAreaPaint={(node, color, ctx) => { const r = node.risk_level === "HIGH" ? 14 : 10; ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); }} linkCanvasObject={(link, ctx) => { const start = link.source; const end = link.target; if ( !start || !end || typeof start.x !== "number" || typeof start.y !== "number" || typeof end.x !== "number" || typeof end.y !== "number" ) return; ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); if (link.is_probable_match) { ctx.setLineDash([4, 4]); ctx.strokeStyle = "rgba(234,179,8,0.5)"; ctx.lineWidth = 1.5; } else { ctx.setLineDash([]); const grad = ctx.createLinearGradient( start.x, start.y, end.x, end.y, ); grad.addColorStop(0, (start.color || "#3b82f6") + "55"); grad.addColorStop(0.5, "rgba(255,255,255,0.12)"); grad.addColorStop(1, (end.color || "#3b82f6") + "55"); ctx.strokeStyle = grad; ctx.lineWidth = 2; } ctx.stroke(); ctx.setLineDash([]); // Relationship label if (link.relationship) { const mx = (start.x + end.x) / 2; const my = (start.y + end.y) / 2; ctx.font = "500 3px Inter, sans-serif"; ctx.fillStyle = "rgba(255,255,255,0.25)"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(link.relationship, mx, my - 3); } }} />
{selectedNode && (
Node Detail

Name

{selectedNode.name}

Type

{selectedNode.type}

Risk Level

{selectedNode.risk_level}
{selectedNode.historical_match && (

Historical Match

Yes

)}
)}
{/* Legend */}
Legend
); }