'use client'; import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import styles from './case.module.css'; import type { ReplayStep, GraphNode, GraphEdge } from '@/lib/types'; import { scenarioToGraph, riskToColor, nodeSize } from '@/lib/dataTransform'; const NODE_SHAPES: Record = { person: 'ellipse', company: 'rectangle', account: 'diamond', transaction: 'triangle', shell: 'hexagon', jurisdiction: 'pentagon', asset: 'barrel', }; const EDGE_COLORS: Record = { ownership: '#8B5CF6', transaction: '#EA580C', association: '#505055', suspicious: '#D4334A', director: '#8B5CF6', }; const getIconSvg = (type: string, color: string) => { let path = ''; switch (type) { case 'person': path = 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'; break; case 'company': path = 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z'; break; case 'shell': path = 'M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44c-.16.12-.36.18-.57.18-.21 0-.41-.06-.57-.18l-7.9-4.44A.991.991 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44c.16-.12.36-.18.57-.18.21 0 .41.06.57.18l7.9 4.44c.32.17.53.5.53.88v9zM12 4.15L6.04 7.5 12 10.85l5.96-3.35L12 4.15zM5 15.91l6 3.38v-6.71L5 9.21v6.7zM19 15.91v-6.7l-6 3.37v6.71l6-3.38z'; break; case 'account': path = 'M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z'; break; case 'transaction': path = 'M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z'; break; case 'asset': path = 'M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3zm5 15h-2v-6H9v6H7v-7.81l5-4.5 5 4.5V18z'; break; default: path = 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z'; break; } const svg = ``; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }; interface Props { graphData: { nodes: GraphNode[]; edges: GraphEdge[] }; } export default function EntityGraph({ graphData }: Props) { const containerRef = useRef(null); const cyRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [tooltipData, setTooltipData] = useState<{ type: 'node' | 'edge'; data: any; x: number; y: number } | null>(null); const prevNodeIdsRef = useRef>(new Set()); // Cola layout config — tuned for spacing and stability const colaLayoutConfig = useMemo(() => ({ name: 'cola', animate: true, animationDuration: 1000, animationEasing: 'ease-in-out-cubic' as any, fit: true, padding: 80, randomize: false, nodeSpacing: 80, edgeLength: 150, convergenceThreshold: 0.01, nodeRepulsion: 8000, idealEdgeLength: 120, avoidOverlap: true, }), []); useEffect(() => { if (!containerRef.current) return; let mounted = true; const initCytoscape = async () => { const cytoscape = (await import('cytoscape')).default; const cola = (await import('cytoscape-cola')).default; if (!mounted || !containerRef.current) return; try { cytoscape.use(cola); } catch { /* already registered */ } const elements = [ ...graphData.nodes.map(n => ({ data: { id: n.id, label: n.label, type: n.type, risk: n.risk, jurisdiction: n.jurisdiction, flagged: n.flagged, pep: n.pep }, })), ...graphData.edges.map(e => ({ data: { id: e.id, source: e.source, target: e.target, type: e.type, label: e.label, amount: e.amount, suspicious: e.suspicious }, })), ]; const cy = cytoscape({ container: containerRef.current, elements, style: [ { selector: 'node', style: { 'background-color': '#1C1C1F', 'width': (ele: any) => nodeSize(ele.data('risk') || 30), 'height': (ele: any) => nodeSize(ele.data('risk') || 30), 'shape': (ele: any) => NODE_SHAPES[ele.data('type')] || 'ellipse', 'color': '#D4D4D4', 'font-size': '9px', 'font-family': 'JetBrains Mono, monospace', 'text-valign': 'bottom', 'text-margin-y': 6, 'text-outline-width': 4, 'text-outline-color': '#131316', 'border-width': 2, 'border-style': (ele: any) => ele.data('type') === 'shell' ? 'dashed' : 'solid', 'border-color': (ele: any) => { if (ele.data('flagged')) return '#D4334A'; return riskToColor(ele.data('risk') || 30); }, 'background-image': (ele: any) => getIconSvg(ele.data('type'), riskToColor(ele.data('risk') || 30)), 'background-width': '50%', 'background-height': '50%', 'background-position-x': '50%', 'background-position-y': '50%', } as any, }, { selector: 'node:selected', style: { 'border-width': 3, 'border-color': '#EA580C', 'overlay-color': '#EA580C', 'overlay-opacity': 0.1, }, }, { selector: 'edge', style: { 'width': (ele: any) => { const amt = ele.data('amount'); if (amt) return Math.max(1, Math.min(4, Math.log10(amt / 100000))); return 1; }, 'line-color': (ele: any) => EDGE_COLORS[ele.data('type')] || '#505055', 'target-arrow-color': (ele: any) => EDGE_COLORS[ele.data('type')] || '#505055', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'font-size': '8px', 'font-family': '"JetBrains Mono", monospace', 'color': '#D4D4D4', 'text-rotation': 'autorotate', 'text-background-opacity': 1, 'text-background-color': '#2A2A2D', 'text-background-padding': 4, 'text-background-shape': 'roundrectangle', 'control-point-step-size': 80, } as any, }, { selector: 'edge[?suspicious]', style: { 'line-color': '#D4334A', 'target-arrow-color': '#D4334A', 'line-style': 'solid', 'width': 2.5, }, }, { selector: '.dimmed', style: { 'opacity': 0.12 }, }, ], layout: colaLayoutConfig as any, }); cyRef.current = cy; prevNodeIdsRef.current = new Set(graphData.nodes.map(n => n.id)); const handleMouseOver = (type: 'node' | 'edge') => (evt: any) => { const ele = evt.target; cy.elements().addClass('dimmed'); if (type === 'node') { ele.neighborhood().add(ele).removeClass('dimmed'); } else { ele.connectedNodes().add(ele).removeClass('dimmed'); } const data = ele.data(); let pos; if (evt.originalEvent && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); pos = { x: evt.originalEvent.clientX - rect.left, y: evt.originalEvent.clientY - rect.top }; } else { pos = type === 'node' ? ele.renderedPosition() : ele.renderedMidpoint(); } let x = pos.x + 16; let y = pos.y - 10; if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); const tooltipWidth = 220; const tooltipHeight = 120; if (x + tooltipWidth > rect.width) { x = pos.x - tooltipWidth - 16; } if (y + 40 + tooltipHeight > rect.height) { y = rect.height - tooltipHeight - 50; } } setTooltipData({ type, data, x, y }); }; cy.on('mouseover', 'node', handleMouseOver('node')); cy.on('mouseover', 'edge', handleMouseOver('edge')); cy.on('mouseout', 'node, edge', () => { cy.elements().removeClass('dimmed'); setTooltipData(null); }); cy.on('tap', 'node', (evt: any) => { const nodeData = evt.target.data() as GraphNode; setSelectedNode(prev => { if (prev?.id === nodeData.id) { evt.target.unselect(); return null; } return nodeData; }); }); cy.on('tap', (evt: any) => { if (evt.target === cy) { setSelectedNode(null); cy.elements().unselect(); } }); }; initCytoscape(); return () => { mounted = false; if (cyRef.current) { cyRef.current.destroy(); cyRef.current = null; } }; }, []); // Initial load only const newElements = useMemo(() => [ ...graphData.nodes.map(n => ({ data: { id: n.id, label: n.label, type: n.type, risk: n.risk, jurisdiction: n.jurisdiction, flagged: n.flagged, pep: n.pep }, })), ...graphData.edges.map(e => ({ data: { id: e.id, source: e.source, target: e.target, type: e.type, label: e.label, amount: e.amount, suspicious: e.suspicious }, })), ], [graphData]); const elementsSignature = useMemo(() => JSON.stringify(newElements), [newElements]); // Incremental layout update — only add new nodes/edges, don't scatter existing ones useEffect(() => { if (!cyRef.current) return; const cy = cyRef.current; if (!containerRef.current) return; const currentNodeIds = new Set(graphData.nodes.map(n => n.id)); const currentEdgeIds = new Set(graphData.edges.map(e => e.id)); const existingNodeIds = new Set(cy.nodes().map((n: any) => n.id())); const existingEdgeIds = new Set(cy.edges().map((e: any) => e.id())); // Find new elements to add const nodesToAdd = newElements.filter( el => el.data.id && !('source' in el.data) && !existingNodeIds.has(el.data.id) ); const edgesToAdd = newElements.filter( el => 'source' in el.data && !existingEdgeIds.has(el.data.id) ); // Find elements to remove const nodeIdsToRemove = [...existingNodeIds].filter(id => !currentNodeIds.has(id)); const edgeIdsToRemove = [...existingEdgeIds].filter(id => !currentEdgeIds.has(id)); let needsLayout = false; if (nodeIdsToRemove.length > 0 || edgeIdsToRemove.length > 0) { [...nodeIdsToRemove, ...edgeIdsToRemove].forEach(id => { const el = cy.getElementById(id); if (el.length) el.remove(); }); needsLayout = true; } if (nodesToAdd.length > 0 || edgesToAdd.length > 0) { cy.add([...nodesToAdd, ...edgesToAdd]); needsLayout = true; } if (needsLayout) { cy.layout({ ...colaLayoutConfig, animate: true, animationDuration: 1000, fit: cy.nodes().length <= 8, randomize: false, } as any).run(); } prevNodeIdsRef.current = currentNodeIds; // eslint-disable-next-line react-hooks/exhaustive-deps }, [elementsSignature]); return (
{/* Floating tooltip */} {tooltipData && tooltipData.type === 'node' && (
{tooltipData.data.type?.toUpperCase()} RISK {tooltipData.data.risk || 0}
{tooltipData.data.label}
{tooltipData.data.jurisdiction && (
Jurisdiction{tooltipData.data.jurisdiction}
)} {tooltipData.data.pep && (
⚠ PEP — Politically Exposed Person
)} {tooltipData.data.flagged && !tooltipData.data.pep && (
⚠ Flagged for review
)}
)} {tooltipData && tooltipData.type === 'edge' && (
{tooltipData.data.type?.toUpperCase() || 'CONNECTION'}
{tooltipData.data.label}
{tooltipData.data.amount && (
Amount${(tooltipData.data.amount).toLocaleString()}
)} {tooltipData.data.suspicious && (
⚠ Suspicious Pattern
)}
)} {/* Selected node detail tray */} {selectedNode && (
{selectedNode.label}
TYPE {selectedNode.type?.toUpperCase()}
RISK SCORE {selectedNode.risk}
{selectedNode.jurisdiction && (
JURISDICTION {selectedNode.jurisdiction}
)} {selectedNode.pep && (
PEP — Politically Exposed Person
)} {selectedNode.flagged && (
⚠ WATCHLIST HIT
)}
)}
); }