diff --git a/resources/js/components/charts/sankey-chart.test.tsx b/resources/js/components/charts/sankey-chart.test.tsx
index 0f744ab3..b1ba2244 100644
--- a/resources/js/components/charts/sankey-chart.test.tsx
+++ b/resources/js/components/charts/sankey-chart.test.tsx
@@ -1,17 +1,46 @@
import { SankeyData } from '@/hooks/use-cashflow-data';
import { Category } from '@/types/category';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import * as React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { SankeyChart } from './sankey-chart';
+// ResponsiveContainer measures its DOM parent, which is 0x0 in jsdom, so the
+// chart never lays out. Clone the child chart with a fixed size instead —
+// Sankey reports whatever width/height it receives as props.
+vi.mock('recharts', async (importOriginal) => {
+ const actual = (await importOriginal()) as typeof import('recharts');
+ return {
+ ...actual,
+ ResponsiveContainer: ({
+ children,
+ }: {
+ children: React.ReactElement<{ width?: number; height?: number }>;
+ }) => React.cloneElement(children, { width: 800, height: 400 }),
+ };
+});
+
+const privacyMode = {
+ isPrivacyModeEnabled: false,
+ togglePrivacyMode: vi.fn(),
+ setPrivacyMode: vi.fn(),
+};
+
vi.mock('@/contexts/privacy-mode-context', () => ({
- usePrivacyMode: () => ({ isPrivacyModeEnabled: false }),
+ usePrivacyMode: vi.fn(() => privacyMode),
}));
vi.mock('@/hooks/use-locale', () => ({
useLocale: () => 'en',
}));
+vi.mock('@/hooks/use-chart-color-scheme', () => ({
+ useChartColors: () => ({
+ cashflowIncomeColor: '#22c55e',
+ cashflowExpenseColor: '#ef4444',
+ }),
+}));
+
vi.mock('@inertiajs/react', () => ({
router: { visit: vi.fn() },
}));
@@ -20,6 +49,8 @@ vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
index: () => ({ url: '/transactions' }),
}));
+import { usePrivacyMode } from '@/contexts/privacy-mode-context';
+
function category(id: string, name: string, color = '#ccc'): Category {
return { id, name, color } as Category;
}
@@ -37,7 +68,6 @@ const data: SankeyData = {
category: category('food', 'Food'),
category_id: 'food',
amount: 310,
- has_children: true,
},
{
category: category('rent', 'Rent'),
@@ -49,165 +79,84 @@ const data: SankeyData = {
total_expense: 810,
};
-const foodChildren: SankeyData = {
- income_categories: [],
- expense_categories: [
- {
- category: category('groceries', 'Groceries'),
- category_id: 'groceries',
- amount: 200,
- },
- {
- category: category('other-groceries', 'Other groceries'),
- category_id: 'other-groceries',
- amount: 110,
- },
- ],
- total_income: 0,
- total_expense: 310,
-};
-
const period = {
from: new Date('2026-06-01'),
to: new Date('2026-06-30'),
};
describe('SankeyChart', () => {
- beforeEach(() => {
- global.fetch = vi.fn().mockResolvedValue({
- json: async () => foodChildren,
- }) as unknown as typeof fetch;
- });
-
afterEach(() => {
vi.clearAllMocks();
});
- it('renders an expand affordance on parent categories with children', () => {
+ it('renders income, center and expense nodes', () => {
render( );
- expect(
- screen.getByRole('button', { name: 'Expand Food' }),
- ).toBeInTheDocument();
- });
-
- it('expands a category into its subcategories without replacing the chart', async () => {
- render( );
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
-
- await waitFor(() => {
- expect(screen.getByText('Groceries')).toBeInTheDocument();
- });
-
- expect(screen.getByText('Other groceries')).toBeInTheDocument();
- // The original chart is untouched: center + parent nodes remain.
+ expect(screen.getByText('Salary')).toBeInTheDocument();
expect(screen.getByText('Cashflow')).toBeInTheDocument();
expect(screen.getByText('Food')).toBeInTheDocument();
expect(screen.getByText('Rent')).toBeInTheDocument();
-
- expect(global.fetch).toHaveBeenCalledWith(
- expect.stringContaining('parent=food'),
- );
});
- it('truncates long category names with an ellipsis and keeps the full name as a title', () => {
- const longName = 'Sports, and sport goods and equipment rentals';
+ it('shows an empty state when there is no cashflow', () => {
render(
,
);
- // The full name stays in the DOM (with a title for hover); CSS handles
- // the visual ellipsis via the `truncate` utility.
- const label = screen.getByTitle(longName);
- expect(label).toHaveTextContent(longName);
- expect(label).toHaveClass('truncate');
+ expect(
+ screen.getByText('No cashflow data for this period'),
+ ).toBeInTheDocument();
});
- it('collapses any other expanded category so only one stays open', async () => {
- const multiParent: SankeyData = {
- ...data,
- expense_categories: [
- {
- category: category('food', 'Food'),
- category_id: 'food',
- amount: 310,
- has_children: true,
- },
- {
- category: category('rent', 'Rent'),
- category_id: 'rent',
- amount: 500,
- has_children: true,
- },
- ],
- };
+ it('groups small categories into an "Other" node', () => {
+ const many = Array.from({ length: 8 }, (_, i) => ({
+ category: category(`c${i}`, `Category ${i}`),
+ category_id: `c${i}`,
+ // First three are large, the rest are tiny (<3%).
+ amount: i < 3 ? 300 : 5,
+ }));
- const rentChildren: SankeyData = {
- income_categories: [],
- expense_categories: [
- {
- category: category('mortgage', 'Mortgage'),
- category_id: 'mortgage',
- amount: 500,
- },
- ],
- total_income: 0,
- total_expense: 500,
- };
+ render(
+ ,
+ );
- global.fetch = vi.fn().mockImplementation((url: string) => {
- const json = url.includes('parent=rent')
- ? rentChildren
- : foodChildren;
-
- return Promise.resolve({ json: async () => json });
- }) as unknown as typeof fetch;
-
- render( );
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
-
- await waitFor(() => {
- expect(screen.getByText('Groceries')).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Rent' }));
-
- await waitFor(() => {
- expect(screen.getByText('Mortgage')).toBeInTheDocument();
- });
-
- // Food's subcategories are gone now that Rent is open.
- expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
+ expect(screen.getByText('Other')).toBeInTheDocument();
+ // The tiny categories are folded away, not rendered individually.
+ expect(screen.queryByText('Category 7')).not.toBeInTheDocument();
});
- it('collapses an expanded category when toggled again', async () => {
+ it('masks amounts in privacy mode', () => {
+ vi.mocked(usePrivacyMode).mockReturnValue({
+ ...privacyMode,
+ isPrivacyModeEnabled: true,
+ });
+
render( );
- fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
-
- await waitFor(() => {
- expect(screen.getByText('Groceries')).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByRole('button', { name: 'Collapse Food' }));
-
- await waitFor(() => {
- expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
- });
+ // No raw digits leak into the labels when privacy mode is on.
+ expect(screen.getByText('Salary')).toBeInTheDocument();
+ expect(document.body.textContent).not.toMatch(/1,?000/);
});
});
diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx
index b1f11c1c..607d9571 100644
--- a/resources/js/components/charts/sankey-chart.tsx
+++ b/resources/js/components/charts/sankey-chart.tsx
@@ -1,26 +1,23 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover';
-import { Separator } from '@/components/ui/separator';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
-import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data';
+import { SankeyData } from '@/hooks/use-cashflow-data';
+import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { useLocale } from '@/hooks/use-locale';
-import {
- calculatePercentage,
- GroupedCategory,
- groupSmallCategories,
-} from '@/lib/sankey-utils';
+import { groupSmallCategories } from '@/lib/sankey-utils';
import { cn } from '@/lib/utils';
-import { Category } from '@/types/category';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { format } from 'date-fns';
-import { ChevronsRight } from 'lucide-react';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ type ComponentProps,
+ type KeyboardEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Layer, ResponsiveContainer, Sankey } from 'recharts';
interface SankeyChartProps {
data: SankeyData;
@@ -31,131 +28,29 @@ interface SankeyChartProps {
period?: { from: Date; to: Date };
}
-type ColumnKey =
- | 'incomeChild'
- | 'income'
- | 'center'
- | 'expense'
- | 'expenseChild';
+type FlowKind = 'income' | 'center' | 'expense';
-interface NodeData {
- id: string;
- label: string;
- value: number;
+// Fields we attach to each node; recharts spreads these onto the payload it
+// hands back to the custom node renderer, alongside its own layout data.
+interface FlowNode {
+ name: string;
+ amount: number;
color: string;
- y: number;
- height: number;
- column: ColumnKey;
- columnFraction: number;
- category?: Category;
- hasChildren?: boolean;
- expandable?: boolean;
+ kind: FlowKind;
+ categoryId?: string;
}
-interface LinkData {
- source: string;
- target: string;
+interface FlowLink {
+ source: number;
+ target: number;
value: number;
- sourceY: number;
- targetY: number;
- sourceHeight: number;
- targetHeight: number;
- kind: 'income' | 'expense';
}
-const NODE_WIDTH = 8;
-const NODE_PADDING = 6;
-const MIN_NODE_HEIGHT = 28;
-const MIN_RENDERED_WIDTH = 400;
-const MAX_RENDERED_WIDTH = 800;
-const LABEL_BLOCK_HEIGHT = 24;
+const NODE_WIDTH = 12;
const LABEL_GAP = 6;
-const LABEL_PAD = 4;
-const LABEL_CENTER_WIDTH = 90;
-
-interface OtherCategoriesBreakdownProps {
- categories: SankeyCategory[];
- total: number;
- currency: string;
- grandTotal: number;
- locale: string;
- isPrivacyModeEnabled: boolean;
-}
-
-function OtherCategoriesBreakdown({
- categories,
- total,
- currency,
- grandTotal,
- locale,
- isPrivacyModeEnabled,
-}: OtherCategoriesBreakdownProps) {
- const maskIfPrivate = (value: number) => {
- const formatted = formatCurrency(value, currency, locale, 0, 0);
- return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
- };
-
- return (
-
-
-
-
- {__('Other Categories (')}
- {categories.length})
-
-
- {__('Categories below 5% of total')}
-
-
-
-
- {categories.map((item) => {
- const percentage = calculatePercentage(
- item.amount,
- grandTotal,
- );
- return (
-
-
-
-
-
- {item.category.name}
-
-
-
-
- {maskIfPrivate(item.amount)}
-
-
- {percentage.toFixed(1)}%
-
-
-
- );
- })}
-
-
-
-
-
- {__('Total')}
- {maskIfPrivate(total)}
-
-
-
- );
-}
+const LABEL_HEIGHT = 34;
+const MUTED_COLOR = 'var(--color-muted)';
+const CENTER_COLOR = 'var(--color-chart-1)';
export function SankeyChart({
data,
@@ -165,36 +60,17 @@ export function SankeyChart({
groupingThreshold = 0.03,
period,
}: SankeyChartProps) {
- const [hoveredNode, setHoveredNode] = useState(null);
- const [hoveredLink, setHoveredLink] = useState(null);
- const [renderedWidth, setRenderedWidth] = useState(MAX_RENDERED_WIDTH);
- const [expandedIds, setExpandedIds] = useState>(new Set());
- const [childrenById, setChildrenById] = useState<
- Record
- >({});
+ const [containerWidth, setContainerWidth] = useState(600);
const containerRef = useRef(null);
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
+ const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors();
- const periodKey = period
- ? `${period.from.getTime()}-${period.to.getTime()}`
- : '';
-
- const maskIfPrivate = (value: number) => {
+ const maskIfPrivate = (value: number): string => {
const formatted = formatCurrency(value, currency, locale, 0, 0);
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
};
- const toggleExpand = (categoryId: string) => {
- setExpandedIds((previous) => {
- if (previous.has(categoryId)) {
- return new Set();
- }
-
- return new Set([categoryId]);
- });
- };
-
useEffect(() => {
const container = containerRef.current;
@@ -202,17 +78,7 @@ export function SankeyChart({
return;
}
- const updateWidth = () => {
- setRenderedWidth(
- Math.round(
- Math.min(
- MAX_RENDERED_WIDTH,
- Math.max(MIN_RENDERED_WIDTH, container.clientWidth),
- ),
- ),
- );
- };
-
+ const updateWidth = () => setContainerWidth(container.clientWidth);
updateWidth();
if (typeof ResizeObserver === 'undefined') {
@@ -227,428 +93,93 @@ export function SankeyChart({
return () => observer.disconnect();
}, []);
- // Changing the period invalidates any expanded subcategories.
- useEffect(() => {
- setExpandedIds(new Set());
- setChildrenById({});
- }, [periodKey]);
+ const { chartData, isEmpty } = useMemo(() => {
+ const {
+ income_categories,
+ expense_categories,
+ total_income,
+ total_expense,
+ } = data;
- // Lazily fetch the children of each newly expanded category.
- useEffect(() => {
- if (!period) {
- return;
+ const nodes: FlowNode[] = [];
+ const links: FlowLink[] = [];
+
+ const groupedIncome = groupSmallCategories(
+ income_categories,
+ total_income,
+ groupingThreshold,
+ );
+ groupedIncome.main.forEach((item) => {
+ nodes.push({
+ name: item.category.name,
+ amount: item.amount,
+ color: item.category.color || cashflowIncomeColor,
+ kind: 'income',
+ categoryId: item.category.id,
+ });
+ });
+ if (groupedIncome.other) {
+ nodes.push({
+ name: __('Other'),
+ amount: groupedIncome.other.total,
+ color: MUTED_COLOR,
+ kind: 'income',
+ });
}
- const missing = [...expandedIds].filter((id) => !(id in childrenById));
+ const centerIndex = nodes.length;
+ nodes.push({
+ name: __('Cashflow'),
+ amount: total_income - total_expense,
+ color: CENTER_COLOR,
+ kind: 'center',
+ });
- if (missing.length === 0) {
- return;
+ const groupedExpense = groupSmallCategories(
+ expense_categories,
+ total_expense,
+ groupingThreshold,
+ );
+ groupedExpense.main.forEach((item) => {
+ nodes.push({
+ name: item.category.name,
+ amount: item.amount,
+ color: item.category.color || cashflowExpenseColor,
+ kind: 'expense',
+ categoryId: item.category.id,
+ });
+ });
+ if (groupedExpense.other) {
+ nodes.push({
+ name: __('Other'),
+ amount: groupedExpense.other.total,
+ color: MUTED_COLOR,
+ kind: 'expense',
+ });
}
- const from = format(period.from, 'yyyy-MM-dd');
- const to = format(period.to, 'yyyy-MM-dd');
- let cancelled = false;
+ nodes.forEach((node, index) => {
+ if (node.amount <= 0) {
+ return;
+ }
- missing.forEach(async (id) => {
- try {
- const response = await fetch(
- `/api/cashflow/sankey?from=${from}&to=${to}&parent=${id}`,
- );
- const json: SankeyData = await response.json();
-
- if (!cancelled) {
- setChildrenById((previous) => ({
- ...previous,
- [id]: json,
- }));
- }
- } catch (error) {
- console.error('Failed to fetch subcategories:', error);
+ if (node.kind === 'income') {
+ links.push({
+ source: index,
+ target: centerIndex,
+ value: node.amount,
+ });
+ } else if (node.kind === 'expense') {
+ links.push({
+ source: centerIndex,
+ target: index,
+ value: node.amount,
+ });
}
});
- return () => {
- cancelled = true;
- };
- }, [expandedIds, childrenById, period, periodKey]);
-
- const { nodes, links, isEmpty, otherGroups, viewBoxTop, viewBoxHeight } =
- useMemo(() => {
- const {
- income_categories,
- expense_categories,
- total_income,
- total_expense,
- } = data;
-
- if (total_income === 0 && total_expense === 0) {
- return {
- nodes: [] as NodeData[],
- links: [] as LinkData[],
- isEmpty: true,
- otherGroups: {} as Record,
- viewBoxTop: 0,
- viewBoxHeight: height,
- };
- }
-
- const otherGroupsMap: Record = {};
- const availableHeight = height - 40; // padding
- const maxTotal = Math.max(total_income, total_expense);
-
- // Income parent nodes (left column)
- const groupedIncome = groupSmallCategories(
- income_categories,
- total_income,
- groupingThreshold,
- );
-
- let incomeY = 20;
- const incomeNodes: NodeData[] = 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: 'income',
- columnFraction: 0,
- category: item.category,
- hasChildren: item.has_children,
- expandable: !!item.has_children,
- };
- incomeY += nodeHeight + NODE_PADDING;
- return node;
- });
-
- if (groupedIncome.other) {
- const nodeHeight = Math.max(
- MIN_NODE_HEIGHT,
- (groupedIncome.other.total / maxTotal) *
- availableHeight *
- 0.5,
- );
- incomeNodes.push({
- id: 'income-other',
- label: __('Other'),
- value: groupedIncome.other.total,
- color: 'var(--color-muted)',
- y: incomeY,
- height: nodeHeight,
- column: 'income',
- columnFraction: 0,
- });
- otherGroupsMap['income-other'] = groupedIncome.other;
- incomeY += nodeHeight + NODE_PADDING;
- }
-
- // 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: 'center',
- columnFraction: 0,
- };
-
- // Expense parent nodes (right column)
- const groupedExpense = groupSmallCategories(
- expense_categories,
- total_expense,
- groupingThreshold,
- );
-
- let expenseY = 20;
- const expenseNodes: NodeData[] = 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: 'expense',
- columnFraction: 0,
- category: item.category,
- hasChildren: item.has_children,
- expandable: !!item.has_children,
- };
- expenseY += nodeHeight + NODE_PADDING;
- return node;
- });
-
- if (groupedExpense.other) {
- const nodeHeight = Math.max(
- MIN_NODE_HEIGHT,
- (groupedExpense.other.total / maxTotal) *
- availableHeight *
- 0.5,
- );
- expenseNodes.push({
- id: 'expense-other',
- label: __('Other'),
- value: groupedExpense.other.total,
- color: 'var(--color-muted)',
- y: expenseY,
- height: nodeHeight,
- column: 'expense',
- columnFraction: 0,
- });
- otherGroupsMap['expense-other'] = groupedExpense.other;
- expenseY += nodeHeight + NODE_PADDING;
- }
-
- // Resolve which expanded parents actually have loaded children.
- const sortByAmount = (
- categories: SankeyCategory[],
- ): SankeyCategory[] =>
- [...categories].sort((a, b) => b.amount - a.amount);
-
- const incomeChildren: Record = {};
- incomeNodes.forEach((node) => {
- if (node.category && expandedIds.has(node.category.id)) {
- const kids =
- childrenById[node.category.id]?.income_categories;
-
- if (kids && kids.length > 0) {
- incomeChildren[node.id] = sortByAmount(kids);
- }
- }
- });
-
- const expenseChildren: Record = {};
- expenseNodes.forEach((node) => {
- if (node.category && expandedIds.has(node.category.id)) {
- const kids =
- childrenById[node.category.id]?.expense_categories;
-
- if (kids && kids.length > 0) {
- expenseChildren[node.id] = sortByAmount(kids);
- }
- }
- });
-
- const hasIncomeChildColumn = Object.keys(incomeChildren).length > 0;
- const hasExpenseChildColumn =
- Object.keys(expenseChildren).length > 0;
-
- // Lay out the active columns left-to-right.
- const columns: ColumnKey[] = [];
- if (hasIncomeChildColumn) {
- columns.push('incomeChild');
- }
- columns.push('income', 'center', 'expense');
- if (hasExpenseChildColumn) {
- columns.push('expenseChild');
- }
-
- const pad = columns.length <= 3 ? 0.25 : 0.12;
- const fractionFor = (index: number): number =>
- columns.length <= 1
- ? 0.5
- : pad + (index / (columns.length - 1)) * (1 - 2 * pad);
- const fractionByColumn = {} as Record;
- columns.forEach((column, index) => {
- fractionByColumn[column] = fractionFor(index);
- });
-
- incomeNodes.forEach((node) => {
- node.columnFraction = fractionByColumn.income;
- });
- expenseNodes.forEach((node) => {
- node.columnFraction = fractionByColumn.expense;
- });
- centerNode.columnFraction = fractionByColumn.center;
-
- const linkList: LinkData[] = [];
-
- // Income parents -> center
- let incomeLinkY = centerY;
- incomeNodes.forEach((incomeNode) => {
- const linkHeight =
- total_income > 0
- ? (incomeNode.value / total_income) * centerHeight
- : 0;
- 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,
- kind: 'income',
- });
- incomeLinkY += linkHeight;
- });
-
- // Center -> expense parents
- let expenseLinkY = centerY;
- expenseNodes.forEach((expenseNode) => {
- const linkHeight =
- total_expense > 0
- ? (expenseNode.value / total_expense) * centerHeight
- : 0;
- 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,
- kind: 'expense',
- });
- expenseLinkY += linkHeight;
- });
-
- // Child nodes stack within their parent's vertical band so the parent
- // visibly splits into its subcategories.
- const childNodes: NodeData[] = [];
- const buildChildren = (
- parents: NodeData[],
- childrenByParent: Record,
- childColumn: ColumnKey,
- kind: 'income' | 'expense',
- ) => {
- parents.forEach((parent) => {
- const kids = childrenByParent[parent.id];
-
- if (!kids) {
- return;
- }
-
- const childFraction = fractionByColumn[childColumn];
- const kidsSum = kids.reduce(
- (sum, kid) => sum + kid.amount,
- 0,
- );
-
- if (kidsSum <= 0) {
- return;
- }
-
- // Size each child like a top-level node (same minimum height
- // and gap), then center the stack on the parent so the links
- // fan out from the parent's proportional slices.
- const childHeights = kids.map((kid) =>
- Math.max(
- MIN_NODE_HEIGHT,
- (kid.amount / maxTotal) * availableHeight * 0.5,
- ),
- );
- const stackHeight =
- childHeights.reduce((sum, h) => sum + h, 0) +
- NODE_PADDING * (kids.length - 1);
- let childCursor =
- parent.y + parent.height / 2 - stackHeight / 2;
- let parentCursor = parent.y;
-
- kids.forEach((kid, index) => {
- const childHeight = childHeights[index];
- const parentSlice =
- (kid.amount / kidsSum) * parent.height;
- const node: NodeData = {
- id: `${childColumn}-${parent.id}-${index}`,
- label: kid.category.name,
- value: kid.amount,
- color:
- kid.category.color ||
- (kind === 'income'
- ? 'var(--color-chart-2)'
- : 'var(--color-chart-3)'),
- y: childCursor,
- height: childHeight,
- column: childColumn,
- columnFraction: childFraction,
- category: kid.category,
- hasChildren: kid.has_children,
- expandable: false,
- };
- childNodes.push(node);
-
- if (kind === 'income') {
- linkList.push({
- source: node.id,
- target: parent.id,
- value: kid.amount,
- sourceY: node.y + childHeight / 2,
- targetY: parentCursor + parentSlice / 2,
- sourceHeight: childHeight,
- targetHeight: parentSlice,
- kind: 'income',
- });
- } else {
- linkList.push({
- source: parent.id,
- target: node.id,
- value: kid.amount,
- sourceY: parentCursor + parentSlice / 2,
- targetY: node.y + childHeight / 2,
- sourceHeight: parentSlice,
- targetHeight: childHeight,
- kind: 'expense',
- });
- }
-
- childCursor += childHeight + NODE_PADDING;
- parentCursor += parentSlice;
- });
- });
- };
-
- buildChildren(incomeNodes, incomeChildren, 'incomeChild', 'income');
- buildChildren(
- expenseNodes,
- expenseChildren,
- 'expenseChild',
- 'expense',
- );
-
- // Expanded child stacks can be taller than their parent band and reach
- // above the top or below the bottom of the base canvas. Grow the
- // viewBox to fit so nothing gets clipped.
- const allNodes = [
- ...incomeNodes,
- centerNode,
- ...expenseNodes,
- ...childNodes,
- ];
- const contentTop = Math.min(...allNodes.map((node) => node.y));
- const contentBottom = Math.max(
- ...allNodes.map((node) => node.y + node.height),
- );
- const viewBoxTop = Math.min(0, contentTop - 20);
- const viewBoxBottom = Math.max(height, contentBottom + 20);
-
- return {
- nodes: allNodes,
- links: linkList,
- isEmpty: false,
- otherGroups: otherGroupsMap,
- viewBoxTop,
- viewBoxHeight: viewBoxBottom - viewBoxTop,
- };
- }, [data, height, groupingThreshold, expandedIds, childrenById]);
+ return { chartData: { nodes, links }, isEmpty: links.length === 0 };
+ }, [data, groupingThreshold, cashflowIncomeColor, cashflowExpenseColor]);
if (isEmpty) {
return (
@@ -664,325 +195,188 @@ export function SankeyChart({
);
}
- const width = renderedWidth;
+ const labelWidth = Math.max(
+ 64,
+ Math.min(140, Math.round(containerWidth * 0.26)),
+ );
+ const sideMargin = labelWidth + LABEL_GAP;
- const isLeftAligned = (column: ColumnKey): boolean =>
- column === 'incomeChild' || column === 'income';
- const isRightAligned = (column: ColumnKey): boolean =>
- column === 'expense' || column === 'expenseChild';
+ const goToCategory = (categoryId: string) => {
+ if (!period) {
+ return;
+ }
+
+ router.visit(
+ transactionsIndex({
+ query: {
+ category_ids: categoryId,
+ date_from: format(period.from, 'yyyy-MM-dd'),
+ date_to: format(period.to, 'yyyy-MM-dd'),
+ },
+ }).url,
+ );
+ };
+
+ const renderNode = ({
+ x,
+ y,
+ width,
+ height: nodeHeight,
+ index,
+ payload,
+ }: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ index: number;
+ payload: FlowNode;
+ }) => {
+ const node = payload;
+ const isCenter = node.kind === 'center';
+ const navigable = !!node.categoryId && !!period;
+
+ let labelX: number;
+ let foWidth: number;
+ let labelY = y + nodeHeight / 2 - LABEL_HEIGHT / 2;
+ let alignClass: string;
+
+ if (node.kind === 'income') {
+ labelX = 2;
+ foWidth = Math.max(0, x - LABEL_GAP - 2);
+ alignClass = 'items-end text-right';
+ } else if (node.kind === 'expense') {
+ labelX = x + width + LABEL_GAP;
+ foWidth = Math.max(0, containerWidth - labelX - 2);
+ alignClass = 'items-start text-left';
+ } else {
+ // The center bar spans the full height, so its label rides on top of
+ // the bar with a subtle background instead of sitting beside it.
+ foWidth = labelWidth;
+ labelX = x + width / 2 - labelWidth / 2;
+ labelY = y + nodeHeight / 2 - LABEL_HEIGHT / 2;
+ alignClass = 'items-center text-center';
+ }
+
+ return (
+ goToCategory(node.categoryId!) : undefined
+ }
+ onKeyDown={
+ navigable
+ ? (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ goToCategory(node.categoryId!);
+ }
+ }
+ : undefined
+ }
+ >
+
+
+
+
+ {node.name}
+
+
+ {maskIfPrivate(node.amount)}
+
+
+
+
+ );
+ };
+
+ const renderLink = ({
+ sourceX,
+ sourceY,
+ sourceControlX,
+ targetX,
+ targetY,
+ targetControlX,
+ linkWidth,
+ index,
+ payload,
+ }: {
+ sourceX: number;
+ sourceY: number;
+ sourceControlX: number;
+ targetX: number;
+ targetY: number;
+ targetControlX: number;
+ linkWidth: number;
+ index: number;
+ payload: { source: FlowNode; target: FlowNode };
+ }) => {
+ const kind =
+ payload.source.kind === 'center'
+ ? payload.target.kind
+ : payload.source.kind;
+ const stroke =
+ kind === 'income' ? cashflowIncomeColor : cashflowExpenseColor;
+
+ return (
+
+ );
+ };
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 =
- sourceNode.columnFraction * width + NODE_WIDTH / 2;
- const targetX =
- targetNode.columnFraction * 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 = node.columnFraction * width - NODE_WIDTH / 2;
- const isHovered = hoveredNode === node.id;
- const isOtherNode = node.id.endsWith('-other');
- const otherGroup = isOtherNode
- ? otherGroups[node.id]
- : null;
- const categoryUrl =
- node.category && period
- ? transactionsIndex({
- query: {
- category_ids: node.category.id,
- date_from: format(
- period.from,
- 'yyyy-MM-dd',
- ),
- date_to: format(
- period.to,
- 'yyyy-MM-dd',
- ),
- },
- }).url
- : null;
- const canExpand =
- !isOtherNode &&
- !!node.expandable &&
- !!node.category &&
- !!period;
- const isExpanded =
- !!node.category &&
- expandedIds.has(node.category.id);
- const isNavigable =
- !isOtherNode && !canExpand && categoryUrl !== null;
- const isInteractive = canExpand || isNavigable;
-
- const activate = () => {
- if (canExpand && node.category) {
- toggleExpand(node.category.id);
- return;
- }
-
- if (categoryUrl) {
- router.visit(categoryUrl);
- }
- };
-
- // The label/amount block is rendered as real HTML in a
- // foreignObject so flexbox handles spacing, vertical
- // centering, and ellipsis truncation reliably.
- const rightAligned = isRightAligned(node.column);
- const leftAligned = isLeftAligned(node.column);
- const labelX = rightAligned
- ? x + NODE_WIDTH + LABEL_GAP
- : leftAligned
- ? LABEL_PAD
- : x + NODE_WIDTH / 2 - LABEL_CENTER_WIDTH / 2;
- const labelWidth = rightAligned
- ? Math.max(0, width - labelX - LABEL_PAD)
- : leftAligned
- ? Math.max(0, x - LABEL_GAP - LABEL_PAD)
- : LABEL_CENTER_WIDTH;
- const labelY =
- node.y + node.height / 2 - LABEL_BLOCK_HEIGHT / 2;
- const iconRotationClass = isExpanded
- ? node.column === 'income'
- ? ''
- : 'rotate-180'
- : node.column === 'income'
- ? 'rotate-180'
- : '';
-
- const nodeContent = (
- setHoveredNode(node.id)}
- onMouseLeave={() => setHoveredNode(null)}
- onClick={() => {
- if (isInteractive) {
- activate();
- }
- }}
- onKeyDown={(event) => {
- if (!isInteractive) {
- return;
- }
-
- if (
- event.key === 'Enter' ||
- event.key === ' '
- ) {
- event.preventDefault();
- activate();
- }
- }}
- role={
- canExpand
- ? 'button'
- : isNavigable
- ? 'link'
- : undefined
- }
- tabIndex={isInteractive ? 0 : undefined}
- aria-label={
- canExpand
- ? isExpanded
- ? `Collapse ${node.label}`
- : `Expand ${node.label}`
- : isNavigable
- ? `View ${node.label} transactions`
- : undefined
- }
- className={cn(
- 'transition-all duration-200',
- isOtherNode && 'cursor-pointer',
- isInteractive && 'cursor-pointer',
- !isOtherNode &&
- !isInteractive &&
- 'cursor-default',
- )}
- >
-
-
- {/* Label + amount */}
-
-
-
-
- {node.label}
- {isOtherNode && (
-
- {' '}
- ⋯
-
- )}
-
- {canExpand && (
-
- )}
-
-
- {maskIfPrivate(node.value)}
-
-
-
-
- );
-
- // Wrap "Other" nodes in Popover
- if (isOtherNode && otherGroup) {
- const grandTotal = node.id.startsWith('income-')
- ? data.total_income
- : data.total_expense;
-
- return (
-
-
- {nodeContent}
-
-
-
-
-
- );
- }
-
- return nodeContent;
- })}
-
-
+
+
+ ['node']}
+ link={renderLink as ComponentProps['link']}
+ nodeWidth={NODE_WIDTH}
+ nodePadding={containerWidth < 420 ? 14 : 22}
+ sort={false}
+ margin={{
+ top: 12,
+ right: sideMargin,
+ bottom: 12,
+ left: sideMargin,
+ }}
+ />
+
);
}
diff --git a/resources/js/lib/sankey-utils.ts b/resources/js/lib/sankey-utils.ts
index 4ea2bc6c..d34de838 100644
--- a/resources/js/lib/sankey-utils.ts
+++ b/resources/js/lib/sankey-utils.ts
@@ -74,11 +74,3 @@ export function groupSmallCategories(
// Don't group - return all categories as main
return { main: sortedCategories, other: null };
}
-
-/**
- * Calculates the percentage of a value relative to a total
- */
-export function calculatePercentage(value: number, total: number): number {
- if (total === 0) return 0;
- return (value / total) * 100;
-}