From 721cbef02464acb40ab1428c769b65a042b4d96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 4 Jun 2026 11:35:26 +0200 Subject: [PATCH] feat: single-open Sankey expand, fit overflowing children (#487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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). --- .../components/charts/sankey-chart.test.tsx | 58 ++ .../js/components/charts/sankey-chart.tsx | 701 +++++++++--------- 2 files changed, 426 insertions(+), 333 deletions(-) diff --git a/resources/js/components/charts/sankey-chart.test.tsx b/resources/js/components/charts/sankey-chart.test.tsx index f12ac497..0f744ab3 100644 --- a/resources/js/components/charts/sankey-chart.test.tsx +++ b/resources/js/components/charts/sankey-chart.test.tsx @@ -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(); + + 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(); diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx index 402f93be..b1f11c1c 100644 --- a/resources/js/components/charts/sankey-chart.tsx +++ b/resources/js/components/charts/sankey-chart.tsx @@ -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(); } - 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, - }; - } - - 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); - } + if (total_income === 0 && total_expense === 0) { + return { + nodes: [] as NodeData[], + links: [] as LinkData[], + isEmpty: true, + otherGroups: {} as Record, + viewBoxTop: 0, + viewBoxHeight: height, + }; } - }); - const expenseChildren: Record = {}; - expenseNodes.forEach((node) => { - if (node.category && expandedIds.has(node.category.id)) { - const kids = childrenById[node.category.id]?.expense_categories; + const otherGroupsMap: Record = {}; + 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; - 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, - ), + 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 = {}; + 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; } - 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)} >