MuazTPM's picture
fix(ui): patch trim typeerror, globe invisible arcs, and leaflet latency
c865cd8
'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<string, string> = {
person: 'ellipse',
company: 'rectangle',
account: 'diamond',
transaction: 'triangle',
shell: 'hexagon',
jurisdiction: 'pentagon',
asset: 'barrel',
};
const EDGE_COLORS: Record<string, string> = {
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="${color}"><path d="${path}"/></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<HTMLDivElement>(null);
const cyRef = useRef<any>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [tooltipData, setTooltipData] = useState<{ type: 'node' | 'edge'; data: any; x: number; y: number } | null>(null);
const prevNodeIdsRef = useRef<Set<string>>(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<string>(cy.nodes().map((n: any) => n.id()));
const existingEdgeIds = new Set<string>(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 (
<div className={styles.graphContainer}>
<div ref={containerRef} className={styles.graphCanvas} />
{/* Floating tooltip */}
{tooltipData && tooltipData.type === 'node' && (
<div className={styles.graphTooltip} style={{ left: tooltipData.x, top: tooltipData.y + 40 }}>
<div className={styles.ttHeader}>
<span className={styles.ttType}>{tooltipData.data.type?.toUpperCase()}</span>
<span className={styles.ttRisk} style={{ color: riskToColor(tooltipData.data.risk || 0) }}>
RISK {tooltipData.data.risk || 0}
</span>
</div>
<div className={styles.ttName}>{tooltipData.data.label}</div>
{tooltipData.data.jurisdiction && (
<div className={styles.ttRow}><span>Jurisdiction</span><span>{tooltipData.data.jurisdiction}</span></div>
)}
{tooltipData.data.pep && (
<div className={styles.ttFlag}>⚠ PEP — Politically Exposed Person</div>
)}
{tooltipData.data.flagged && !tooltipData.data.pep && (
<div className={styles.ttFlag}>⚠ Flagged for review</div>
)}
</div>
)}
{tooltipData && tooltipData.type === 'edge' && (
<div className={styles.graphTooltip} style={{ left: tooltipData.x, top: tooltipData.y + 40 }}>
<div className={styles.ttHeader}>
<span className={styles.ttType}>{tooltipData.data.type?.toUpperCase() || 'CONNECTION'}</span>
</div>
<div className={styles.ttName}>{tooltipData.data.label}</div>
{tooltipData.data.amount && (
<div className={styles.ttRow}><span>Amount</span><span>${(tooltipData.data.amount).toLocaleString()}</span></div>
)}
{tooltipData.data.suspicious && (
<div className={styles.ttFlag}>⚠ Suspicious Pattern</div>
)}
</div>
)}
{/* Selected node detail tray */}
{selectedNode && (
<div className={styles.detailTray}>
<div className={styles.detailHeader}>
<span className={styles.detailTitle}>{selectedNode.label}</span>
<button className="nx-btn" onClick={() => {
setSelectedNode(null);
cyRef.current?.elements().unselect();
}}>✕</button>
</div>
<div className={styles.detailBody}>
<div className={styles.detailRow}>
<span className="nx-label">TYPE</span>
<span>{selectedNode.type?.toUpperCase()}</span>
</div>
<div className={styles.detailRow}>
<span className="nx-label">RISK SCORE</span>
<span style={{ color: riskToColor(selectedNode.risk || 0) }}>{selectedNode.risk}</span>
</div>
{selectedNode.jurisdiction && (
<div className={styles.detailRow}>
<span className="nx-label">JURISDICTION</span>
<span>{selectedNode.jurisdiction}</span>
</div>
)}
{selectedNode.pep && (
<div className={styles.detailFlag}>PEP — Politically Exposed Person</div>
)}
{selectedNode.flagged && (
<div className={styles.detailFlag}>⚠ WATCHLIST HIT</div>
)}
</div>
</div>
)}
</div>
);
}