diff --git a/resources/js/components/charts/sankey-chart.test.tsx b/resources/js/components/charts/sankey-chart.test.tsx
index 0f744ab3..ab8807e8 100644
--- a/resources/js/components/charts/sankey-chart.test.tsx
+++ b/resources/js/components/charts/sankey-chart.test.tsx
@@ -1,17 +1,47 @@
import { SankeyData } from '@/hooks/use-cashflow-data';
import { Category } from '@/types/category';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import * as React from 'react';
import { afterEach, beforeEach, 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',
+ categoryBarColor: (color: string) => color || '#999',
+ }),
+}));
+
vi.mock('@inertiajs/react', () => ({
router: { visit: vi.fn() },
}));
@@ -20,6 +50,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;
}
@@ -58,8 +90,8 @@ const foodChildren: SankeyData = {
amount: 200,
},
{
- category: category('other-groceries', 'Other groceries'),
- category_id: 'other-groceries',
+ category: category('eating-out', 'Eating out'),
+ category_id: 'eating-out',
amount: 110,
},
],
@@ -83,119 +115,77 @@ describe('SankeyChart', () => {
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('Cashflow')).toBeInTheDocument();
+ expect(screen.getByText('Salary')).toBeInTheDocument();
+ expect(screen.getByText('Net')).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('shows an expand affordance on expense categories with children', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: 'Expand Food' }),
+ ).toBeInTheDocument();
+ // Rent has no children, so it links straight to its transactions.
+ expect(
+ screen.getByRole('link', { name: 'View Rent transactions' }),
+ ).toBeInTheDocument();
+ });
+
+ it('expands an expense category into its subcategories on click', async () => {
render();
fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
@@ -204,10 +194,25 @@ describe('SankeyChart', () => {
expect(screen.getByText('Groceries')).toBeInTheDocument();
});
- fireEvent.click(screen.getByRole('button', { name: 'Collapse Food' }));
+ expect(screen.getByText('Eating out')).toBeInTheDocument();
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('parent=food'),
+ );
+ // The rest of the chart stays in place.
+ expect(screen.getByText('Rent')).toBeInTheDocument();
+ expect(screen.getByText('Net')).toBeInTheDocument();
+ });
- await waitFor(() => {
- expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
+ it('masks amounts in privacy mode', () => {
+ vi.mocked(usePrivacyMode).mockReturnValue({
+ ...privacyMode,
+ isPrivacyModeEnabled: true,
});
+
+ render();
+
+ // 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..d1ba08b5 100644
--- a/resources/js/components/charts/sankey-chart.tsx
+++ b/resources/js/components/charts/sankey-chart.tsx
@@ -1,26 +1,24 @@
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 { ChevronDown, ChevronRight } from 'lucide-react';
+import {
+ type ComponentProps,
+ type KeyboardEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Layer, ResponsiveContainer, Sankey } from 'recharts';
interface SankeyChartProps {
data: SankeyData;
@@ -31,131 +29,45 @@ interface SankeyChartProps {
period?: { from: Date; to: Date };
}
-type ColumnKey =
- | 'incomeChild'
- | 'income'
- | 'center'
- | 'expense'
- | 'expenseChild';
+type FlowKind = 'income' | 'center' | 'expense';
+type LabelSide = 'left' | 'right' | 'onbar';
-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;
+ kind: FlowKind;
+ labelSide: LabelSide;
+ categoryId?: string;
expandable?: boolean;
+ expanded?: boolean;
}
-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 NODE_PADDING = 24;
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 = 30;
+// On-bar labels (the hub, and an expanded parent) are bordered pills with two
+// lines, so they need a little more room than the plain side labels.
+const PILL_LABEL_HEIGHT = 44;
+// A Sankey is inherently horizontal, so on narrow screens we let it scroll
+// sideways (same pattern as the trend chart) rather than crushing the flows.
+const MIN_CHART_WIDTH = 560;
+// A drill-down adds a fourth column, so widen the canvas to keep it readable.
+const EXPANDED_MIN_CHART_WIDTH = 760;
+// Gives each node enough vertical room that its two-line label stays legible
+// even when a category's bar is tiny.
+const ROW_HEIGHT = 46;
+const MUTED_COLOR = 'var(--color-muted)';
+const CENTER_COLOR = 'var(--color-chart-1)';
export function SankeyChart({
data,
@@ -165,34 +77,30 @@ 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 [containerWidth, setContainerWidth] = useState(600);
+ const [expandedId, setExpandedId] = useState(null);
const [childrenById, setChildrenById] = useState<
Record
>({});
const containerRef = useRef(null);
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
+ const { cashflowIncomeColor, cashflowExpenseColor, categoryBarColor } =
+ 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]);
- });
+ setExpandedId((previous) =>
+ previous === categoryId ? null : categoryId,
+ );
};
useEffect(() => {
@@ -202,17 +110,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,21 +125,15 @@ export function SankeyChart({
return () => observer.disconnect();
}, []);
- // Changing the period invalidates any expanded subcategories.
+ // A new period (or refreshed data) invalidates any open drill-down.
useEffect(() => {
- setExpandedIds(new Set());
+ setExpandedId(null);
setChildrenById({});
}, [periodKey]);
- // Lazily fetch the children of each newly expanded category.
+ // Lazily fetch the subcategories of the expanded parent.
useEffect(() => {
- if (!period) {
- return;
- }
-
- const missing = [...expandedIds].filter((id) => !(id in childrenById));
-
- if (missing.length === 0) {
+ if (!period || !expandedId || childrenById[expandedId]) {
return;
}
@@ -249,406 +141,192 @@ export function SankeyChart({
const to = format(period.to, 'yyyy-MM-dd');
let cancelled = false;
- missing.forEach(async (id) => {
- try {
- const response = await fetch(
- `/api/cashflow/sankey?from=${from}&to=${to}&parent=${id}`,
- );
- const json: SankeyData = await response.json();
-
+ fetch(`/api/cashflow/sankey?from=${from}&to=${to}&parent=${expandedId}`)
+ .then((response) => response.json())
+ .then((json: SankeyData) => {
if (!cancelled) {
setChildrenById((previous) => ({
...previous,
- [id]: json,
+ [expandedId]: json,
}));
}
- } catch (error) {
+ })
+ .catch((error) => {
+ // Collapse back so the node doesn't stay stuck "expanded" with
+ // no subcategories and no way forward.
+ if (!cancelled) {
+ setExpandedId((current) =>
+ current === expandedId ? null : current,
+ );
+ }
console.error('Failed to fetch subcategories:', error);
- }
- });
+ });
return () => {
cancelled = true;
};
- }, [expandedIds, childrenById, period, periodKey]);
+ }, [expandedId, childrenById, period, periodKey]);
- const { nodes, links, isEmpty, otherGroups, viewBoxTop, viewBoxHeight } =
- useMemo(() => {
- const {
- income_categories,
- expense_categories,
- total_income,
- total_expense,
- } = data;
+ const { chartData, isEmpty, nodeRows, subcatRows } = 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 nodes: FlowNode[] = [];
+ const links: FlowLink[] = [];
+
+ const groupedIncome = groupSmallCategories(
+ income_categories,
+ total_income,
+ groupingThreshold,
+ );
+ groupedIncome.main.forEach((item, index) => {
+ if (item.amount <= 0) {
+ return;
}
- const otherGroupsMap: Record = {};
- const availableHeight = height - 40; // padding
- const maxTotal = Math.max(total_income, total_expense);
+ nodes.push({
+ name: item.category.name,
+ amount: item.amount,
+ color: categoryBarColor(item.category.color, index),
+ kind: 'income',
+ labelSide: 'left',
+ categoryId: item.category.id,
+ });
+ });
+ if (groupedIncome.other) {
+ nodes.push({
+ name: __('Other'),
+ amount: groupedIncome.other.total,
+ color: MUTED_COLOR,
+ kind: 'income',
+ labelSide: 'left',
+ });
+ }
- // Income parent nodes (left column)
- const groupedIncome = groupSmallCategories(
- income_categories,
- total_income,
- groupingThreshold,
+ const centerIndex = nodes.length;
+ nodes.push({
+ // "Net" rather than "Cashflow": the card title already reads
+ // "Cashflow", so the hub only needs to carry the net amount.
+ name: __('Net'),
+ amount: total_income - total_expense,
+ color: CENTER_COLOR,
+ kind: 'center',
+ labelSide: 'onbar',
+ });
+
+ const groupedExpense = groupSmallCategories(
+ expense_categories,
+ total_expense,
+ groupingThreshold,
+ );
+ groupedExpense.main.forEach((item, index) => {
+ if (item.amount <= 0) {
+ return;
+ }
+
+ const isExpanded = item.category.id === expandedId;
+ // Keep the label beside the bar until the subcategories actually
+ // load, so it doesn't jump onto the bar and back during the fetch.
+ const childrenLoaded =
+ isExpanded &&
+ (childrenById[item.category.id]?.expense_categories?.length ??
+ 0) > 0;
+
+ nodes.push({
+ name: item.category.name,
+ amount: item.amount,
+ color: categoryBarColor(item.category.color, index),
+ kind: 'expense',
+ // An expanded parent sits between the hub and its subcategory
+ // column, so its label moves onto the bar to clear the way.
+ labelSide: childrenLoaded ? 'onbar' : 'right',
+ categoryId: item.category.id,
+ expandable: !!item.has_children,
+ expanded: isExpanded,
+ });
+ });
+ if (groupedExpense.other) {
+ nodes.push({
+ name: __('Other'),
+ amount: groupedExpense.other.total,
+ color: MUTED_COLOR,
+ kind: 'expense',
+ labelSide: 'right',
+ });
+ }
+
+ nodes.forEach((node, index) => {
+ if (node.amount <= 0) {
+ return;
+ }
+
+ 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,
+ });
+ }
+ });
+
+ // Drill-down: split the expanded parent into its subcategory column.
+ let subcatRows = 0;
+ if (expandedId) {
+ const parentIndex = nodes.findIndex(
+ (node) =>
+ node.kind === 'expense' && node.categoryId === expandedId,
);
+ const kids = [
+ ...(childrenById[expandedId]?.expense_categories ?? []),
+ ].sort((a, b) => b.amount - a.amount);
- 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) {
+ if (parentIndex >= 0) {
+ kids.forEach((kid, index) => {
+ if (kid.amount <= 0) {
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;
+ const childIndex = nodes.length;
+ nodes.push({
+ name: kid.category.name,
+ amount: kid.amount,
+ color: categoryBarColor(kid.category.color, index),
+ kind: 'expense',
+ labelSide: 'right',
+ categoryId: kid.category.id,
});
+ links.push({
+ source: parentIndex,
+ target: childIndex,
+ value: kid.amount,
+ });
+ subcatRows += 1;
});
- };
+ }
+ }
- buildChildren(incomeNodes, incomeChildren, 'incomeChild', 'income');
- buildChildren(
- expenseNodes,
- expenseChildren,
- 'expenseChild',
- 'expense',
- );
+ const incomeRows =
+ groupedIncome.main.length + (groupedIncome.other ? 1 : 0);
+ const expenseRows =
+ groupedExpense.main.length + (groupedExpense.other ? 1 : 0);
- // 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,
+ nodeRows: Math.max(incomeRows, expenseRows, subcatRows),
+ subcatRows,
+ };
+ }, [data, groupingThreshold, categoryBarColor, expandedId, childrenById]);
if (isEmpty) {
return (
@@ -664,325 +342,238 @@ export function SankeyChart({
);
}
- const width = renderedWidth;
+ const labelWidth = Math.max(
+ 64,
+ Math.min(140, Math.round(containerWidth * 0.26)),
+ );
+ const sideMargin = labelWidth + LABEL_GAP;
+ // Grow the canvas so crowded sides (many expense categories) keep their
+ // labels legible instead of overlapping.
+ const chartHeight = Math.max(height, nodeRows * ROW_HEIGHT + 24);
+ const minChartWidth =
+ subcatRows > 0 ? EXPANDED_MIN_CHART_WIDTH : MIN_CHART_WIDTH;
- 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 isPill = node.labelSide === 'onbar';
+ const expandable = !!node.expandable && !!node.categoryId && !!period;
+ const navigable = !expandable && !!node.categoryId && !!period;
+ const interactive = expandable || navigable;
+
+ const activate = () => {
+ if (expandable) {
+ toggleExpand(node.categoryId!);
+ } else if (navigable) {
+ goToCategory(node.categoryId!);
+ }
+ };
+
+ const labelBoxHeight = isPill ? PILL_LABEL_HEIGHT : LABEL_HEIGHT;
+ const labelY = y + nodeHeight / 2 - labelBoxHeight / 2;
+ let labelX: number;
+ let labelBoxWidth: number;
+ let alignClass: string;
+
+ if (node.labelSide === 'left') {
+ labelX = 2;
+ labelBoxWidth = Math.max(0, x - LABEL_GAP - 2);
+ alignClass = 'items-end text-right';
+ } else if (node.labelSide === 'right') {
+ labelX = x + width + LABEL_GAP;
+ // Cap the width so a non-rightmost parent (one sitting to the left
+ // of an expanded subcategory column) can't stretch its label across
+ // that column.
+ labelBoxWidth = Math.max(
+ 0,
+ Math.min(labelWidth, containerWidth - labelX - 2),
+ );
+ alignClass = 'items-start text-left';
+ } else {
+ labelBoxWidth = labelWidth;
+ labelX = x + width / 2 - labelWidth / 2;
+ alignClass = 'items-center text-center';
+ }
+
+ const ChevronIcon = node.expanded ? ChevronDown : ChevronRight;
+
+ return (
+ {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ activate();
+ }
+ }
+ : undefined
+ }
+ >
+
+
+
+
+
+ {node.name}
+
+ {expandable && (
+
+ )}
+
+
+ {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 (
-
-