| 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 ( | |
| <div className="flex items-center gap-sm"> | |
| {dashed ? ( | |
| <svg width="24" height="8"> | |
| <line | |
| x1="0" | |
| y1="4" | |
| x2="24" | |
| y2="4" | |
| stroke={color} | |
| strokeWidth="2" | |
| strokeDasharray="4 3" | |
| /> | |
| </svg> | |
| ) : ( | |
| <div | |
| style={{ | |
| width: "12px", | |
| height: "12px", | |
| borderRadius: "50%", | |
| background: color, | |
| flexShrink: 0, | |
| }} | |
| /> | |
| )} | |
| <span style={{ color: "var(--text-muted)", fontSize: "12px" }}> | |
| {label} | |
| </span> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div | |
| className="flex flex-col items-center justify-center" | |
| style={{ padding: "80px 24px", gap: "12px" }} | |
| > | |
| <p style={{ fontSize: "15px", fontWeight: 500 }}> | |
| Loading entity graph… | |
| </p> | |
| </div> | |
| ); | |
| } | |
| if (!nodes || nodes.length === 0) { | |
| return ( | |
| <div | |
| className="flex flex-col items-center justify-center" | |
| style={{ padding: "80px 24px", gap: "12px" }} | |
| > | |
| <p style={{ fontSize: "15px", fontWeight: 500 }}> | |
| No entity relationships detected in uploaded documents. | |
| </p> | |
| <p style={{ color: "var(--text-muted)", fontSize: "13px" }}> | |
| Entity graph is built from promoter names, related companies, and loan | |
| cross-references extracted via NER. | |
| </p> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="flex flex-col gap-md"> | |
| {/* Alert banners */} | |
| {hasProbableMatch && ( | |
| <div | |
| className="card" | |
| style={{ | |
| background: "var(--warning-subtle)", | |
| borderColor: "rgba(234,179,8,0.3)", | |
| }} | |
| > | |
| <p style={{ color: "var(--warning)", fontSize: "13px" }}> | |
| ⚠ Fuzzy-matched entities detected. Manual verification recommended. | |
| </p> | |
| </div> | |
| )} | |
| {hasHighRisk && ( | |
| <div | |
| className="card" | |
| style={{ | |
| background: "var(--danger-subtle)", | |
| borderColor: "rgba(239,68,68,0.3)", | |
| }} | |
| > | |
| <p style={{ color: "var(--danger)", fontSize: "13px" }}> | |
| 🚩 Related-party anomaly detected. Possible shell company or fund | |
| siphoning risk. | |
| </p> | |
| </div> | |
| )} | |
| {historicalNodes.map((node) => ( | |
| <div | |
| key={node.id} | |
| className="card" | |
| style={{ | |
| background: "var(--danger-subtle)", | |
| borderColor: "var(--danger)", | |
| }} | |
| > | |
| <p | |
| style={{ | |
| color: "var(--danger)", | |
| fontSize: "13px", | |
| fontWeight: 700, | |
| marginBottom: "4px", | |
| }} | |
| > | |
| ⚠ HISTORICAL MATCH DETECTED | |
| </p> | |
| <p style={{ color: "var(--danger)", fontSize: "12px" }}> | |
| Director DIN {node.id} appeared in a previously rejected | |
| application. Escalate. | |
| </p> | |
| </div> | |
| ))} | |
| {/* Graph + sidebar */} | |
| <div className="flex gap-md"> | |
| <div | |
| className="card" | |
| style={{ | |
| flex: 1, | |
| overflow: "hidden", | |
| padding: 0, | |
| background: | |
| "linear-gradient(135deg, #1a1a1f 0%, #1e1e28 50%, #1a1a22 100%)", | |
| position: "relative", | |
| }} | |
| > | |
| <div | |
| style={{ | |
| position: "absolute", | |
| inset: 0, | |
| opacity: 0.04, | |
| background: | |
| "radial-gradient(circle at 50% 50%, #3b82f6 0%, transparent 60%)", | |
| pointerEvents: "none", | |
| }} | |
| /> | |
| <ForceGraph2D | |
| ref={graphRef} | |
| graphData={graphData} | |
| nodeRelSize={6} | |
| nodeVal={(n) => (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); | |
| } | |
| }} | |
| /> | |
| </div> | |
| {selectedNode && ( | |
| <div | |
| className="card shrink-0" | |
| style={{ | |
| width: "220px", | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: "12px", | |
| }} | |
| > | |
| <div className="flex justify-between items-center"> | |
| <span className="label">Node Detail</span> | |
| <button | |
| onClick={() => setSelectedNode(null)} | |
| style={{ | |
| background: "none", | |
| border: "none", | |
| cursor: "pointer", | |
| color: "var(--text-muted)", | |
| }} | |
| > | |
| <X size={14} /> | |
| </button> | |
| </div> | |
| <div className="flex flex-col gap-sm"> | |
| <div> | |
| <p className="label" style={{ marginBottom: "2px" }}> | |
| Name | |
| </p> | |
| <p | |
| style={{ | |
| fontSize: "13px", | |
| fontWeight: 600, | |
| wordBreak: "break-word", | |
| }} | |
| > | |
| {selectedNode.name} | |
| </p> | |
| </div> | |
| <div> | |
| <p className="label" style={{ marginBottom: "2px" }}> | |
| Type | |
| </p> | |
| <p style={{ fontSize: "13px", textTransform: "capitalize" }}> | |
| {selectedNode.type} | |
| </p> | |
| </div> | |
| <div> | |
| <p className="label" style={{ marginBottom: "4px" }}> | |
| Risk Level | |
| </p> | |
| <span className={`badge ${riskBadge(selectedNode.risk_level)}`}> | |
| {selectedNode.risk_level} | |
| </span> | |
| </div> | |
| {selectedNode.historical_match && ( | |
| <div> | |
| <p className="label" style={{ marginBottom: "2px" }}> | |
| Historical Match | |
| </p> | |
| <p style={{ color: "var(--danger)", fontSize: "12px" }}> | |
| Yes | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Legend */} | |
| <div className="card"> | |
| <span | |
| className="label" | |
| style={{ display: "block", marginBottom: "12px" }} | |
| > | |
| Legend | |
| </span> | |
| <div className="flex flex-wrap gap-lg"> | |
| <LegendDot color="#3b82f6" label="Person (Promoter / Director)" /> | |
| <LegendDot color="#8b5cf6" label="Company" /> | |
| <LegendDot color="#eab308" label="Loan / Facility" /> | |
| <LegendDot color="#ef4444" label="HIGH RISK entity" /> | |
| <LegendDot color="#eab308" dashed label="Probable Match (fuzzy)" /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |