feat: single-open Sankey expand, fit overflowing children (#487)

## What

- Expanding a category on the Sankey chart now **collapses any other
open category**, so only one subcategory column is shown at a time.
- Grow the SVG \`viewBox\` to the real content bounds so tall child
stacks no longer get **clipped above or below** the canvas.

## Why

Multiple simultaneously-expanded categories cluttered the chart, and a
parent with 3+ children produced a stack taller than the canvas — the
top entries were cut off (e.g. "Traveling €2,011" rendered half-hidden).

## Tests

- Added a test asserting opening a second parent collapses the first.
- Existing expand/collapse tests still pass (5/5).
This commit is contained in:
Víctor Falcón 2026-06-04 11:35:26 +02:00 committed by GitHub
parent 53d051800b
commit 721cbef024
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 426 additions and 333 deletions

View File

@ -137,6 +137,64 @@ describe('SankeyChart', () => {
expect(label).toHaveClass('truncate');
});
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,
},
],
};
const rentChildren: SankeyData = {
income_categories: [],
expense_categories: [
{
category: category('mortgage', 'Mortgage'),
category_id: 'mortgage',
amount: 500,
},
],
total_income: 0,
total_expense: 500,
};
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(<SankeyChart data={multiParent} period={period} />);
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();
});
it('collapses an expanded category when toggled again', async () => {
render(<SankeyChart data={data} period={period} />);

View File

@ -187,15 +187,11 @@ export function SankeyChart({
const toggleExpand = (categoryId: string) => {
setExpandedIds((previous) => {
const next = new Set(previous);
if (next.has(categoryId)) {
next.delete(categoryId);
} else {
next.add(categoryId);
if (previous.has(categoryId)) {
return new Set<string>();
}
return next;
return new Set([categoryId]);
});
};
@ -276,344 +272,383 @@ export function SankeyChart({
};
}, [expandedIds, childrenById, period, periodKey]);
const { nodes, links, isEmpty, otherGroups } = useMemo(() => {
const {
income_categories,
expense_categories,
total_income,
total_expense,
} = data;
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<string, GroupedCategory>,
};
}
const otherGroupsMap: Record<string, GroupedCategory> = {};
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<string, SankeyCategory[]> = {};
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);
}
if (total_income === 0 && total_expense === 0) {
return {
nodes: [] as NodeData[],
links: [] as LinkData[],
isEmpty: true,
otherGroups: {} as Record<string, GroupedCategory>,
viewBoxTop: 0,
viewBoxHeight: height,
};
}
});
const expenseChildren: Record<string, SankeyCategory[]> = {};
expenseNodes.forEach((node) => {
if (node.category && expandedIds.has(node.category.id)) {
const kids = childrenById[node.category.id]?.expense_categories;
const otherGroupsMap: Record<string, GroupedCategory> = {};
const availableHeight = height - 40; // padding
const maxTotal = Math.max(total_income, total_expense);
if (kids && kids.length > 0) {
expenseChildren[node.id] = sortByAmount(kids);
}
}
});
// Income parent nodes (left column)
const groupedIncome = groupSmallCategories(
income_categories,
total_income,
groupingThreshold,
);
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<ColumnKey, number>;
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<string, SankeyCategory[]>,
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,
),
let incomeY = 20;
const incomeNodes: NodeData[] = groupedIncome.main.map((item) => {
const nodeHeight = Math.max(
MIN_NODE_HEIGHT,
(item.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;
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;
});
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 (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;
}
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',
});
// 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<string, SankeyCategory[]> = {};
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<string, SankeyCategory[]> = {};
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<ColumnKey, number>;
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<string, SankeyCategory[]>,
childColumn: ColumnKey,
kind: 'income' | 'expense',
) => {
parents.forEach((parent) => {
const kids = childrenByParent[parent.id];
if (!kids) {
return;
}
childCursor += childHeight + NODE_PADDING;
parentCursor += parentSlice;
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');
buildChildren(incomeNodes, incomeChildren, 'incomeChild', 'income');
buildChildren(
expenseNodes,
expenseChildren,
'expenseChild',
'expense',
);
return {
nodes: [...incomeNodes, centerNode, ...expenseNodes, ...childNodes],
links: linkList,
isEmpty: false,
otherGroups: otherGroupsMap,
};
}, [data, height, groupingThreshold, expandedIds, childrenById]);
// 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]);
if (isEmpty) {
return (
@ -642,7 +677,7 @@ export function SankeyChart({
className={cn('w-full overflow-x-auto', className)}
>
<svg
viewBox={`0 0 ${width} ${height}`}
viewBox={`0 ${viewBoxTop} ${width} ${viewBoxHeight}`}
className="mx-auto block"
style={{ width: renderedWidth, minWidth: MIN_RENDERED_WIDTH }}
preserveAspectRatio="xMidYMid meet"