import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Separator } from '@/components/ui/separator'; import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data'; import { calculatePercentage, GroupedCategory, groupSmallCategories, } from '@/lib/sankey-utils'; import { cn } from '@/lib/utils'; import { Category } from '@/types/category'; import { __ } from '@/utils/i18n'; import { useMemo, useState } from 'react'; interface SankeyChartProps { data: SankeyData; height?: number; className?: string; currency?: string; groupingThreshold?: number; } interface NodeData { id: string; label: string; value: number; color: string; y: number; height: number; column: 0 | 1 | 2; category?: Category; } interface LinkData { source: string; target: string; value: number; sourceY: number; targetY: number; sourceHeight: number; targetHeight: number; } const COLUMN_POSITIONS = [0.25, 0.5, 0.75]; const NODE_WIDTH = 8; const NODE_PADDING = 6; const MIN_NODE_HEIGHT = 20; function formatAmount(amountInCents: number, currency: string): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency, minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(amountInCents / 100); } interface OtherCategoriesBreakdownProps { categories: SankeyCategory[]; total: number; currency: string; grandTotal: number; } function OtherCategoriesBreakdown({ categories, total, currency, grandTotal, }: OtherCategoriesBreakdownProps) { return (

{__('Other Categories (')} {categories.length})

{__('Categories below 5% of total')}

{categories.map((item) => { const percentage = calculatePercentage( item.amount, grandTotal, ); return (
{item.category.name}
{formatAmount(item.amount, currency)} {percentage.toFixed(1)}%
); })}
{__('Total')} {formatAmount(total, currency)}
); } export function SankeyChart({ data, height = 400, className, currency = 'USD', groupingThreshold = 0.03, }: SankeyChartProps) { const [hoveredNode, setHoveredNode] = useState(null); const [hoveredLink, setHoveredLink] = useState(null); const { nodes, links, isEmpty, otherGroups } = useMemo(() => { const { income_categories, expense_categories, total_income, total_expense, } = data; if (total_income === 0 && total_expense === 0) { return { nodes: [], links: [], isEmpty: true, otherGroups: {}, }; } const nodeMap: Record = {}; const linkList: LinkData[] = []; const otherGroupsMap: Record = {}; // Calculate available height for nodes const availableHeight = height - 40; // padding const maxTotal = Math.max(total_income, total_expense); // Group income categories const groupedIncome = groupSmallCategories( income_categories, total_income, groupingThreshold, ); // Create income nodes (left column) let incomeY = 20; const incomeNodes = groupedIncome.main.map((item) => { const nodeHeight = Math.max( MIN_NODE_HEIGHT, (item.amount / maxTotal) * availableHeight * 0.5, ); const node: NodeData = { id: `income-${item.category_id}`, label: item.category.name, value: item.amount, color: item.category.color || 'var(--color-chart-2)', y: incomeY, height: nodeHeight, column: 0, category: item.category, }; incomeY += nodeHeight + NODE_PADDING; return node; }); // Add "Other" income node if needed if (groupedIncome.other) { const nodeHeight = Math.max( MIN_NODE_HEIGHT, (groupedIncome.other.total / maxTotal) * availableHeight * 0.5, ); const otherNode: NodeData = { id: 'income-other', label: __('Other'), value: groupedIncome.other.total, color: 'var(--color-muted)', y: incomeY, height: nodeHeight, column: 0, }; incomeNodes.push(otherNode); otherGroupsMap['income-other'] = groupedIncome.other; incomeY += nodeHeight + NODE_PADDING; } // Create center node (total cashflow) const centerHeight = Math.max( MIN_NODE_HEIGHT * 1.5, (Math.max(total_income, total_expense) / maxTotal) * availableHeight * 0.6, ); const centerY = (height - centerHeight) / 2; const centerNode: NodeData = { id: 'center', label: __('Cashflow'), value: total_income - total_expense, color: 'var(--color-chart-1)', y: centerY, height: centerHeight, column: 1, }; // Group expense categories const groupedExpense = groupSmallCategories( expense_categories, total_expense, groupingThreshold, ); // Create expense nodes (right column) let expenseY = 20; const expenseNodes = groupedExpense.main.map((item) => { const nodeHeight = Math.max( MIN_NODE_HEIGHT, (item.amount / maxTotal) * availableHeight * 0.5, ); const node: NodeData = { id: `expense-${item.category_id}`, label: item.category.name, value: item.amount, color: item.category.color || 'var(--color-chart-3)', y: expenseY, height: nodeHeight, column: 2, category: item.category, }; expenseY += nodeHeight + NODE_PADDING; return node; }); // Add "Other" expense node if needed if (groupedExpense.other) { const nodeHeight = Math.max( MIN_NODE_HEIGHT, (groupedExpense.other.total / maxTotal) * availableHeight * 0.5, ); const otherNode: NodeData = { id: 'expense-other', label: __('Other'), value: groupedExpense.other.total, color: 'var(--color-muted)', y: expenseY, height: nodeHeight, column: 2, }; expenseNodes.push(otherNode); otherGroupsMap['expense-other'] = groupedExpense.other; expenseY += nodeHeight + NODE_PADDING; } // Add all nodes to map incomeNodes.forEach((n) => (nodeMap[n.id] = n)); nodeMap[centerNode.id] = centerNode; expenseNodes.forEach((n) => (nodeMap[n.id] = n)); // Create links from income to center let incomeLinkY = centerY; incomeNodes.forEach((incomeNode) => { const linkHeight = (incomeNode.value / total_income) * centerHeight; linkList.push({ source: incomeNode.id, target: 'center', value: incomeNode.value, sourceY: incomeNode.y + incomeNode.height / 2, targetY: incomeLinkY + linkHeight / 2, sourceHeight: incomeNode.height, targetHeight: linkHeight, }); incomeLinkY += linkHeight; }); // Create links from center to expenses let expenseLinkY = centerY; expenseNodes.forEach((expenseNode) => { const linkHeight = (expenseNode.value / total_expense) * centerHeight; linkList.push({ source: 'center', target: expenseNode.id, value: expenseNode.value, sourceY: expenseLinkY + linkHeight / 2, targetY: expenseNode.y + expenseNode.height / 2, sourceHeight: linkHeight, targetHeight: expenseNode.height, }); expenseLinkY += linkHeight; }); return { nodes: Object.values(nodeMap), links: linkList, isEmpty: false, otherGroups: otherGroupsMap, }; }, [data, height, groupingThreshold]); if (isEmpty) { return (
{__('No cashflow data for this period')}
); } const width = 600; // SVG viewBox width return (
{/* Links */} {links.map((link) => { const sourceNode = nodes.find( (n) => n.id === link.source, ); const targetNode = nodes.find( (n) => n.id === link.target, ); if (!sourceNode || !targetNode) return null; const sourceX = COLUMN_POSITIONS[sourceNode.column] * width + NODE_WIDTH / 2; const targetX = COLUMN_POSITIONS[targetNode.column] * width - NODE_WIDTH / 2; const linkId = `${link.source}-${link.target}`; const isHovered = hoveredLink === linkId || hoveredNode === link.source || hoveredNode === link.target; // Create a curved path const path = ` M ${sourceX} ${link.sourceY - link.sourceHeight / 2} C ${(sourceX + targetX) / 2} ${link.sourceY - link.sourceHeight / 2}, ${(sourceX + targetX) / 2} ${link.targetY - link.targetHeight / 2}, ${targetX} ${link.targetY - link.targetHeight / 2} L ${targetX} ${link.targetY + link.targetHeight / 2} C ${(sourceX + targetX) / 2} ${link.targetY + link.targetHeight / 2}, ${(sourceX + targetX) / 2} ${link.sourceY + link.sourceHeight / 2}, ${sourceX} ${link.sourceY + link.sourceHeight / 2} Z `; return ( setHoveredLink(linkId)} onMouseLeave={() => setHoveredLink(null)} /> ); })} {/* Nodes */} {nodes.map((node) => { const x = COLUMN_POSITIONS[node.column] * width - NODE_WIDTH / 2; const isHovered = hoveredNode === node.id; const isOtherNode = node.id.endsWith('-other'); const otherGroup = isOtherNode ? otherGroups[node.id] : null; const nodeContent = ( setHoveredNode(node.id)} onMouseLeave={() => setHoveredNode(null)} className={cn( 'transition-all duration-200', isOtherNode && 'cursor-pointer', !isOtherNode && 'cursor-default', )} > {/* Label */} {node.label} {isOtherNode && ( {' '} ⋯ )} {/* Amount */} {formatAmount(node.value, currency)} ); // Wrap "Other" nodes in Popover if (isOtherNode && otherGroup) { const grandTotal = node.id.startsWith('income-') ? data.total_income : data.total_expense; return ( {nodeContent} ); } return nodeContent; })}
); }