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 (
{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 (
);
}