Network Graph Component
Network Graph Component
setMaxTransactions(maxFreq);
// Set minTransactions to 1/3 of maxFreq (not 2/3)
setMinTransactions(Math.ceil(maxFreq / 4) || 1);
}, [data]);
switch (action) {
case "zoomIn":
svg.transition().duration(300).call(zoomRef.current.scaleBy, 1.3);
break;
case "zoomOut":
svg.transition().duration(300).call(zoomRef.current.scaleBy, 0.7);
break;
case "home":
fitGraphToScreen();
break;
default:
break;
}
};
if (nodes.empty()) return;
nodes.each(function () {
const node = d3.select(this);
const transform = node.attr("transform");
if (transform) {
const match = /translate\(([^,]+),([^)]+)\)/.exec(transform);
if (match) {
const x = parseFloat(match[1]);
const y = parseFloat(match[2]);
minX -= horizPadding;
maxX += horizPadding;
minY -= vertPadding;
maxY += vertPadding;
useEffect(() => {
if (!graphContainerRef.current) return;
return () => {
container.removeEventListener("click", handleClick);
};
}, []);
useEffect(() => {
if (!graphContainerRef.current) return;
// Cleanup
return () => {
if (graphContainerRef.current) {
resizeObserver.unobserve(graphContainerRef.current);
}
resizeObserver.disconnect();
};
}, []);
setTimeout(() => {
fitGraphToScreen();
}, 500);
// Cleanup
return () => {
if (graphContainerRef.current) {
resizeObserver.unobserve(graphContainerRef.current);
}
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
if (
!data ||
data.length === 0 ||
!svgRef.current ||
!graphContainerRef.current
)
return;
d3.select(svgRef.current).selectAll("*").remove();
try {
// ------------- STEP 1: COMPUTE ENTITY FREQUENCIES -------------
const entityFrequency = {};
data.forEach((row) => {
const entity = row.entity;
if (!entity) return;
entityFrequency[entity] = (entityFrequency[entity] || 0) + 1;
});
return true;
});
if (!filteredRows.length) {
const width = graphContainerRef.current.clientWidth;
const height = 600;
const svg = d3
.select(svgRef.current)
.attr("width", width)
.attr("height", height);
svg
.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.attr("font-size", "20px")
.text("No data matches the current filter criteria");
return;
}
filteredRows.forEach((row) => {
const person = row.name;
const entity = row.entity;
const debit = row.debit || 0;
const credit = row.credit || 0;
if (person) {
nameSet.add(person);
}
if (entity) {
if (!entityConnections[entity]) {
entityConnections[entity] = new Set();
}
entityConnections[entity].add(person);
}
if (
person &&
entity &&
person === entity &&
(debit > 0 || credit > 0)
) {
if (!selfTransfers.has(person)) {
selfTransfers.set(person, {
debit: 0,
credit: 0,
total: 0,
debitTransactions: [],
creditTransactions: [],
});
}
if (debit > 0) {
selfTransferData.debit += debit;
selfTransferData.total += debit;
selfTransferData.debitTransactions.push(row);
}
if (credit > 0) {
selfTransferData.credit += credit;
selfTransferData.total += credit;
selfTransferData.creditTransactions.push(row);
}
selfTransfers.set(person, selfTransferData);
}
});
Object.keys(entityConnections).forEach((entity) => {
if (!nameSet.has(entity)) {
entitySet.add(entity);
}
});
filteredRows.forEach((row) => {
const person = row.name;
const entity = row.entity;
const debit = row.debit || 0;
const credit = row.credit || 0;
if (existingEdge) {
existingEdge.value += debit;
existingEdge.transactions.push(row);
} else {
edges.push({
source: person,
target: entity,
type: "debit",
value: debit,
transactions: [row],
});
}
}
if (existingEdge) {
existingEdge.value += credit;
existingEdge.transactions.push(row);
} else {
edges.push({
source: entity,
target: person,
type: "credit",
value: credit,
transactions: [row],
});
}
}
});
nodes.push({
id: person,
label: person,
type: "Person",
isMainEntity,
hasSelfTransfer,
selfTransferData: hasSelfTransfer ? selfTransfers.get(person) : null,
radius: calculateNodeRadius(person, isMainEntity, "Person"),
color: colorPalette.Person,
});
}
nodes.push({
id: entity,
label: entity,
type: isCommon ? "CommonEntity" : "Entity",
isMainEntity,
hasSelfTransfer,
selfTransferData: hasSelfTransfer ? selfTransfers.get(entity) : null,
radius: calculateNodeRadius(
entity,
isMainEntity,
isCommon ? "CommonEntity" : "Entity"
),
color: isCommon ? colorPalette.CommonEntity : colorPalette.Entity,
});
}
const svg = d3
.select(svgRef.current)
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
const g = svg.append("g");
// Define the arrow marker
const defs = svg.append("defs");
defs
.append("marker")
.attr("id", "arrow-selftransfer")
.attr("viewBox", "0 0 10 10")
.attr("refX", "7") // Adjust refX for better alignment
.attr("refY", "5")
.attr("markerWidth", "6") // Reduced width
.attr("markerHeight", "6")
.attr("orient", "auto-start-reverse") // Ensures it follows path direction
.append("path")
.attr("d", "M 0 0 L 10 5 L 0 10 z") // Triangle shape
.attr("fill", "#6A0DAD");
const zoom = d3
.zoom()
.scaleExtent([0.1, 4])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
if (
MAIN_ENTITY_IDS.includes(d.source.id) &&
MAIN_ENTITY_IDS.includes(d.target.id)
) {
return 400; // Increased distance between main entities for better
horizontal spread
} else if (isMainEntity) {
return 180; // Connected entities should be at optimal distance
} else {
return 200;
}
})
)
const lineGenerator = d3
.line()
.x((d) => d.x)
.y((d) => d.y);
const link = g
.append("g")
.attr("stroke-opacity", 0.6)
.selectAll("path")
.data(edges)
.join("path")
.attr("class", "edge")
.attr("fill", "none")
.attr("stroke", (d) =>
d.type === "debit" ? colorPalette.Debit : colorPalette.Credit
)
.attr("stroke-width", edgeThickness)
.attr("marker-end", (d) => `url(#arrow-${d.type})`)
.attr("d", (d) =>
lineGenerator([
{ x: d.source.x, y: d.source.y },
{ x: d.target.x, y: d.target.y },
])
);
const node = g
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.attr("class", "node")
.call(drag) // Apply the drag behavior
.on("click", (event, d) => {
// Prevent the click event from firing during drag
if (event.defaultPrevented) return;
setSelectedNode({
id: d.id,
data: {
type: d.type,
isMainEntity: d.isMainEntity,
hasSelfTransfer: d.hasSelfTransfer,
selfTransferData: d.selfTransferData,
},
});
setIsDialogOpen(true);
});
node
.append("circle")
.attr("r", (d) => d.radius)
.attr("fill", (d) => d.color)
.attr("stroke", "#2C3E50")
.attr("stroke-width", (d) => (d.isMainEntity ? 4 : 2));
// Node Labels
// In the node.each function - around line 620
node.each(function (d) {
const nodeEl = d3.select(this);
const words = d.label.split(/\s+/);
const lineHeight = Math.max(12, d.radius * 0.25); // Scale lineHeight with
node radius
// Better font size calculation - proportional to node radius
const fontSize = Math.max(10, Math.min(16, d.radius * 0.3)); // Min 10px,
max 16px, otherwise proportional
const yOffset = d.radius * 0.1;
words.forEach((word, i) => {
text
.append("tspan")
.attr("x", 0)
.attr("y", yOffset + i * lineHeight)
.text(word);
});
// Create separate groups for debit and credit edge labels to better manage
them
const debitEdgeLabels = g
.append("g")
.selectAll("g")
.data(edges.filter((d) => d.type === "debit"))
.join("g");
const creditEdgeLabels = g
.append("g")
.selectAll("g")
.data(edges.filter((d) => d.type === "credit"))
.join("g");
// Add text labels for debit edges with improved visibility and positioning
debitEdgeLabels
.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-0.5em") // Position above the line
.attr("font-size", "11px")
.attr("font-weight", "bold")
.attr("fill", colorPalette.Debit)
.attr("stroke", "white")
.attr("stroke-width", "3px")
.attr("paint-order", "stroke")
.text((d) => `₹${d.value.toLocaleString()}`);
// Add text labels for credit edges with improved visibility and positioning
creditEdgeLabels
.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.2em") // Position below the line
.attr("font-size", "11px")
.attr("font-weight", "bold")
.attr("fill", colorPalette.Credit)
.attr("stroke", "white")
.attr("stroke-width", "3px")
.attr("paint-order", "stroke")
.text((d) => `₹${d.value.toLocaleString()}`);
simulation.tick(100);
updatePositions();
simulation.stop();
// Initialize the graph view after a short delay to ensure positions are
calculated
setTimeout(() => {
fitGraphToScreen();
}, 500);
function updatePositions() {
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
// Improved edge path creation with greater separation between debit and
credit
link.attr("d", (d) => {
const sourceNode = nodes.find((n) => n.id === d.source.id);
const targetNode = nodes.find((n) => n.id === d.target.id);
// Position debit edge labels with improved offset to follow the curve
debitEdgeLabels.attr("transform", (d) => {
if (!d.source || !d.target || !d.pathCoordinates)
return "translate(0,0)";
return `translate(${labelX},${labelY})`;
});
// Position credit edge labels with improved offset to follow the curve
creditEdgeLabels.attr("transform", (d) => {
if (!d.source || !d.target || !d.pathCoordinates)
return "translate(0,0)";
return `translate(${labelX},${labelY})`;
});
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
const svg = d3
.select(svgRef.current)
.attr("width", width)
.attr("height", height);
svg
.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.attr("fill", "red")
.text("Error rendering graph. Please check console for details.");
}
}, [data, minTransactions, minAmount, nodeSize, edgeThickness]);
return data.filter(
(tx) => tx.name === selectedNode.id || tx.entity === selectedNode.id
);
}, [selectedNode, data]);
return (
<div className="space-y-4">
<Card className="w-full max-w-screen-4xl mx-auto shadow-md border border-
gray-300">
<CardContent className="p-6">
<div className="grid grid-cols-3 gap-8 items-start w-full">
{/* Node Size */}
<div className="flex flex-col w-full">
<label className="text-sm font-semibold mb-2">Node Size</label>
<div className="mt-1 rounded-lg w-full flex">
<SliderDemo
defaultValue={[nodeSize]}
max={100}
step={5}
onChange={(value) => setNodeSize(value[0])}
className="w-full"
/>
</div>
</div>
<div
ref={graphContainerRef}
className="h-[600px] border rounded-lg bg-white relative"
>
{/* Zoom control buttons */}
<div className="absolute top-4 left-4 flex space-x-2 z-10 bg-white bg-
opacity-80 p-1 rounded-md shadow-md">
<button
onClick={() => handleZoomControl("home")}
className="p-2 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
title="Reset view"
>
<Home size={18} />
</button>
<button
onClick={() => handleZoomControl("zoomIn")}
className="p-2 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
title="Zoom in"
>
<ZoomIn size={18} />
</button>
<button
onClick={() => handleZoomControl("zoomOut")}
className="p-2 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
title="Zoom out"
>
<ZoomOut size={18} />
</button>
</div>
<svg ref={svgRef} width="100%" height="600"></svg>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-7xl overflow-auto">
<ScrollArea className="h-[80vh]">
<UnifiedTable
title={`All Transactions for ${selectedNode?.id}`}
data={nodeTransactions || []}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
);
};