diff --git a/src/components/desktop/AccountAndCategorySankeyChart.vue b/src/components/desktop/AccountAndCategorySankeyChart.vue index 68f93d45..bd1963ab 100644 --- a/src/components/desktop/AccountAndCategorySankeyChart.vue +++ b/src/components/desktop/AccountAndCategorySankeyChart.vue @@ -18,6 +18,7 @@ import type { SortableTransactionStatisticDataItem, TransactionStatisticResponseItemWithInfo } from '@/models/transaction.ts'; +import type { Account } from '@/models/account.ts'; import { values } from '@/core/base.ts'; import { ThemeType } from '@/core/theme.ts'; @@ -28,6 +29,15 @@ import { isNumber } from '@/lib/common.ts'; import { sortStatisticsItems } from '@/lib/statistics.ts'; import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; +enum SankeyChartDepth { + PrimaryIncomeCategory = 0, + SecondaryIncomeCategory = 1, + Account = 2, + AccountWithTransfer = 3, + SecondaryExpenseCategory = 4, + PrimaryExpenseCategory = 5 +} + enum SankeyChartNodeItemType { Account = 'account', Category = 'category' @@ -89,6 +99,7 @@ const sankeyData = computed(() => { const secondaryExpenseCategoryNodesMap: Record = {}; const primaryExpenseCategoryNodesMap: Record = {}; const linksMap: Record = {}; + const accountsMap: Record = {}; for (const item of props.items) { if (!item.primaryAccount || !item.account || !item.primaryCategory || !item.category || !item.amountInDefaultCurrency) { @@ -105,6 +116,7 @@ const sankeyData = computed(() => { const incomeAccountNameId = `income_account:${item.account.id}`; const expenseAccountNameId = `expense_account:${item.account.id}`; + accountsMap[item.account.id] = item.account; updateNodeItem(incomeAccountNodesMap, { itemType: SankeyChartNodeItemType.Account, @@ -112,8 +124,8 @@ const sankeyData = computed(() => { name: item.account.name, nameId: incomeAccountNameId, displayOrders: [item.account.displayOrder], - amount: (item.primaryCategory.type == CategoryType.Income || item.primaryCategory.type == CategoryType.Transfer) ? item.amountInDefaultCurrency : 0, - depth: 2 + amount: item.primaryCategory.type == CategoryType.Income ? item.amountInDefaultCurrency : 0, + depth: SankeyChartDepth.Account }); updateNodeItem(expenseAccountNodesMap, { @@ -123,7 +135,7 @@ const sankeyData = computed(() => { nameId: expenseAccountNameId, displayOrders: [item.account.displayOrder], amount: item.primaryCategory.type == CategoryType.Expense ? item.amountInDefaultCurrency : 0, - depth: 3 + depth: SankeyChartDepth.AccountWithTransfer }); if (item.primaryCategory.type == CategoryType.Income) { @@ -134,7 +146,7 @@ const sankeyData = computed(() => { nameId: item.primaryCategory.id, displayOrders: [item.primaryCategory.displayOrder], amount: item.amountInDefaultCurrency, - depth: 0 + depth: SankeyChartDepth.PrimaryIncomeCategory }); updateNodeItem(secondaryIncomeCategoryNodesMap, { @@ -144,7 +156,7 @@ const sankeyData = computed(() => { nameId: item.category.id, displayOrders: [item.primaryCategory.displayOrder, item.category.displayOrder], amount: item.amountInDefaultCurrency, - depth: 1 + depth: SankeyChartDepth.SecondaryIncomeCategory }); updateLinkItem(linksMap, { @@ -178,7 +190,7 @@ const sankeyData = computed(() => { nameId: item.category.id, displayOrders: [item.primaryCategory.displayOrder, item.category.displayOrder], amount: item.amountInDefaultCurrency, - depth: 4 + depth: SankeyChartDepth.SecondaryExpenseCategory }); updateNodeItem(primaryExpenseCategoryNodesMap, { @@ -188,7 +200,7 @@ const sankeyData = computed(() => { nameId: item.primaryCategory.id, displayOrders: [item.primaryCategory.displayOrder], amount: item.amountInDefaultCurrency, - depth: 5 + depth: SankeyChartDepth.PrimaryExpenseCategory }); updateLinkItem(linksMap, { @@ -216,6 +228,7 @@ const sankeyData = computed(() => { }); } else if (item.primaryCategory.type == CategoryType.Transfer && item.relatedAccount) { const relatedAccountNameId = `expense_account:${item.relatedAccount.id}`; + accountsMap[item.relatedAccount.id] = item.relatedAccount; updateLinkItem(linksMap, { sourceItemType: SankeyChartNodeItemType.Account, sourceItemId: item.account.id, @@ -230,20 +243,17 @@ const sankeyData = computed(() => { } } - for (const expenseAccountNode of values(expenseAccountNodesMap)) { - const incomeAccountNode = incomeAccountNodesMap[`income_account:${expenseAccountNode.itemId}`]; - - if (!incomeAccountNode) { - continue; - } + for (const account of values(accountsMap)) { + const incomeAccountNameId = `income_account:${account.id}`; + const expenseAccountNameId = `expense_account:${account.id}`; let totalOutflowAmount = 0; let totalInflowAmount = 0; for (const link of values(linksMap)) { - if (link.sourceItemType === SankeyChartNodeItemType.Account && link.sourceItemId === expenseAccountNode.itemId) { + if (link.sourceItemType === SankeyChartNodeItemType.Account && link.sourceItemId === account.id) { totalOutflowAmount += link.value; - } else if (link.targetItemType === SankeyChartNodeItemType.Account && link.targetItemId === expenseAccountNode.itemId) { + } else if (link.targetItemType === SankeyChartNodeItemType.Account && link.targetItemId === account.id) { totalInflowAmount += link.value; } } @@ -251,16 +261,38 @@ const sankeyData = computed(() => { const amountDifference = totalOutflowAmount - totalInflowAmount; if (amountDifference > 0) { + updateNodeItem(incomeAccountNodesMap, { + itemType: SankeyChartNodeItemType.Account, + id: account.id, + name: account.name, + nameId: incomeAccountNameId, + displayOrders: [account.displayOrder], + amount: amountDifference, + depth: SankeyChartDepth.AccountWithTransfer + }); + } else if (amountDifference < 0) { + updateNodeItem(expenseAccountNodesMap, { + itemType: SankeyChartNodeItemType.Account, + id: account.id, + name: account.name, + nameId: expenseAccountNameId, + displayOrders: [account.displayOrder], + amount: -amountDifference, + depth: SankeyChartDepth.AccountWithTransfer + }); + } + + if (Math.abs(amountDifference) > 0) { updateLinkItem(linksMap, { sourceItemType: SankeyChartNodeItemType.Account, - sourceItemId: incomeAccountNode.itemId, - source: incomeAccountNode.nameId, - sourceName: incomeAccountNode.displayName, + sourceItemId: account.id, + source: incomeAccountNameId, + sourceName: account.name, targetItemType: SankeyChartNodeItemType.Account, - targetItemId: expenseAccountNode.itemId, - target: expenseAccountNode.nameId, - targetName: expenseAccountNode.displayName, - value: amountDifference + targetItemId: account.id, + target: expenseAccountNameId, + targetName: account.name, + value: Math.abs(amountDifference) }); } } @@ -295,14 +327,15 @@ const chartOptions = computed(() => { color: isDarkMode.value ? '#eee' : '#333' }, formatter: (params: CallbackDataParams) => { - const value = isNumber(params.value) ? params.value as number : 0; - const displayValue = formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency); - if (params.dataType === 'node') { const dataItem = params.data as SankeyChartNodeItem; + const value = dataItem.totalAmount; + const displayValue = formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency); return `
${dataItem.displayName}${displayValue}
`; } else if (params.dataType === 'edge') { const dataItem = params.data as SankeyChartLinkItem; + const value = isNumber(params.value) ? params.value as number : 0; + const displayValue = formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency); return `
${dataItem.sourceDisplayName} → ${dataItem.targetDisplayName}${displayValue}
`; } else { return ''; @@ -316,6 +349,7 @@ const chartOptions = computed(() => { top: 10, bottom: 10, roam: true, + layoutIterations: 0, label: { formatter: (params: CallbackDataParams) => { const dataItem = params.data as SankeyChartNodeItem; @@ -324,7 +358,7 @@ const chartOptions = computed(() => { }, levels: [ { - depth: 0, + depth: SankeyChartDepth.PrimaryIncomeCategory, itemStyle: { color: expenseIncomeAmountColor.incomeAmountColor, opacity: 0.6 @@ -335,7 +369,7 @@ const chartOptions = computed(() => { } }, { - depth: 1, + depth: SankeyChartDepth.SecondaryIncomeCategory, itemStyle: { color: expenseIncomeAmountColor.incomeAmountColor, opacity: 0.4 @@ -346,7 +380,7 @@ const chartOptions = computed(() => { } }, { - depth: 2, + depth: SankeyChartDepth.Account, itemStyle: { color: '#c07d43', opacity: 0.5 @@ -357,7 +391,7 @@ const chartOptions = computed(() => { } }, { - depth: 3, + depth: SankeyChartDepth.AccountWithTransfer, itemStyle: { color: '#c07d43', opacity: 0.5 @@ -368,7 +402,7 @@ const chartOptions = computed(() => { } }, { - depth: 4, + depth: SankeyChartDepth.SecondaryExpenseCategory, itemStyle: { color: expenseIncomeAmountColor.expenseAmountColor, opacity: 0.4 @@ -379,7 +413,7 @@ const chartOptions = computed(() => { } }, { - depth: 5, + depth: SankeyChartDepth.PrimaryExpenseCategory, itemStyle: { color: expenseIncomeAmountColor.expenseAmountColor, opacity: 0.6