mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-14 15:07:33 +08:00
522 lines
19 KiB
Vue
522 lines
19 KiB
Vue
<template>
|
|
<v-chart autoresize class="account-category-sankey-chart-container" :class="{ 'transition-in': skeleton }" :option="chartOptions"
|
|
@click="clickItem" />
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import { useTheme } from 'vuetify';
|
|
|
|
import type { ECElementEvent } from 'echarts/core';
|
|
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
|
|
|
import { useI18n } from '@/locales/helpers.ts';
|
|
|
|
import { useUserStore } from '@/stores/user.ts';
|
|
|
|
import type {
|
|
SortableTransactionStatisticDataItem,
|
|
TransactionStatisticResponseItemWithInfo
|
|
} from '@/models/transaction.ts';
|
|
|
|
import { values } from '@/core/base.ts';
|
|
import { ThemeType } from '@/core/theme.ts';
|
|
import { CategoryType } from '@/core/category.ts';
|
|
import { TransactionRelatedAccountType } from '@/core/transaction.ts';
|
|
|
|
import { isNumber } from '@/lib/common.ts';
|
|
import { sortStatisticsItems } from '@/lib/statistics.ts';
|
|
import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
|
|
|
enum SankeyChartNodeItemType {
|
|
Account = 'account',
|
|
Category = 'category'
|
|
}
|
|
|
|
interface SankeyChartData {
|
|
nodes: SankeyChartNodeItem[];
|
|
links: SankeyChartLinkItem[];
|
|
}
|
|
|
|
interface SankeyChartNodeItem extends SortableTransactionStatisticDataItem {
|
|
itemType: SankeyChartNodeItemType;
|
|
itemId: string;
|
|
name: string;
|
|
nameId: string;
|
|
displayName: string;
|
|
displayOrders: number[];
|
|
totalAmount: number;
|
|
depth: number;
|
|
}
|
|
|
|
interface SankeyChartLinkItem {
|
|
sourceItemType: SankeyChartNodeItemType;
|
|
sourceItemId: string;
|
|
source: string;
|
|
sourceDisplayName: string;
|
|
targetItemType: SankeyChartNodeItemType;
|
|
targetItemId: string;
|
|
target: string;
|
|
targetDisplayName: string;
|
|
value: number;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
skeleton?: boolean;
|
|
items: TransactionStatisticResponseItemWithInfo[];
|
|
sortingType: number;
|
|
defaultCurrency?: string;
|
|
enableClickItem?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'click', sourceItemType: 'account' | 'category', sourceItemId: string, targetItemType?: 'account' | 'category', targetItemId?: string): void;
|
|
}>();
|
|
|
|
const theme = useTheme();
|
|
|
|
const { formatAmountToLocalizedNumeralsWithCurrency } = useI18n();
|
|
|
|
const userStore = useUserStore();
|
|
|
|
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
|
|
|
const sankeyData = computed<SankeyChartData>(() => {
|
|
const primaryIncomeCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
|
|
const secondaryIncomeCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
|
|
const incomeAccountNodesMap: Record<string, SankeyChartNodeItem> = {};
|
|
const expenseAccountNodesMap: Record<string, SankeyChartNodeItem> = {};
|
|
const secondaryExpenseCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
|
|
const primaryExpenseCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
|
|
const linksMap: Record<string, SankeyChartLinkItem> = {};
|
|
|
|
for (const item of props.items) {
|
|
if (!item.primaryAccount || !item.account || !item.primaryCategory || !item.category || !item.amountInDefaultCurrency) {
|
|
continue;
|
|
}
|
|
|
|
if (item.account.hidden || item.primaryAccount.hidden || item.category.hidden || item.primaryCategory.hidden) {
|
|
continue;
|
|
}
|
|
|
|
if (item.relatedAccount && (item.relatedAccountType === TransactionRelatedAccountType.TransferFrom || item.relatedAccount.hidden || !item.relatedPrimaryAccount || item.relatedPrimaryAccount.hidden)) {
|
|
continue;
|
|
}
|
|
|
|
const incomeAccountNameId = `income_account:${item.account.id}`;
|
|
const expenseAccountNameId = `expense_account:${item.account.id}`;
|
|
|
|
updateNodeItem(incomeAccountNodesMap, {
|
|
itemType: SankeyChartNodeItemType.Account,
|
|
id: item.account.id,
|
|
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
|
|
});
|
|
|
|
updateNodeItem(expenseAccountNodesMap, {
|
|
itemType: SankeyChartNodeItemType.Account,
|
|
id: item.account.id,
|
|
name: item.account.name,
|
|
nameId: expenseAccountNameId,
|
|
displayOrders: [item.account.displayOrder],
|
|
amount: item.primaryCategory.type == CategoryType.Expense ? item.amountInDefaultCurrency : 0,
|
|
depth: 3
|
|
});
|
|
|
|
if (item.primaryCategory.type == CategoryType.Income) {
|
|
updateNodeItem(primaryIncomeCategoryNodesMap, {
|
|
itemType: SankeyChartNodeItemType.Category,
|
|
id: item.primaryCategory.id,
|
|
name: item.primaryCategory.name,
|
|
nameId: item.primaryCategory.id,
|
|
displayOrders: [item.primaryCategory.displayOrder],
|
|
amount: item.amountInDefaultCurrency,
|
|
depth: 0
|
|
});
|
|
|
|
updateNodeItem(secondaryIncomeCategoryNodesMap, {
|
|
itemType: SankeyChartNodeItemType.Category,
|
|
id: item.category.id,
|
|
name: item.category.name,
|
|
nameId: item.category.id,
|
|
displayOrders: [item.primaryCategory.displayOrder, item.category.displayOrder],
|
|
amount: item.amountInDefaultCurrency,
|
|
depth: 1
|
|
});
|
|
|
|
updateLinkItem(linksMap, {
|
|
sourceItemType: SankeyChartNodeItemType.Category,
|
|
sourceItemId: item.primaryCategory.id,
|
|
source: item.primaryCategory.id,
|
|
sourceName: item.primaryCategory.name,
|
|
targetItemType: SankeyChartNodeItemType.Category,
|
|
targetItemId: item.category.id,
|
|
target: item.category.id,
|
|
targetName: item.category.name,
|
|
value: item.amountInDefaultCurrency
|
|
});
|
|
|
|
updateLinkItem(linksMap, {
|
|
sourceItemType: SankeyChartNodeItemType.Category,
|
|
sourceItemId: item.category.id,
|
|
source: item.category.id,
|
|
sourceName: item.category.name,
|
|
targetItemType: SankeyChartNodeItemType.Account,
|
|
targetItemId: item.account.id,
|
|
target: incomeAccountNameId,
|
|
targetName: item.account.name,
|
|
value: item.amountInDefaultCurrency
|
|
});
|
|
} else if (item.primaryCategory.type == CategoryType.Expense) {
|
|
updateNodeItem(secondaryExpenseCategoryNodesMap, {
|
|
itemType: SankeyChartNodeItemType.Category,
|
|
id: item.category.id,
|
|
name: item.category.name,
|
|
nameId: item.category.id,
|
|
displayOrders: [item.primaryCategory.displayOrder, item.category.displayOrder],
|
|
amount: item.amountInDefaultCurrency,
|
|
depth: 4
|
|
});
|
|
|
|
updateNodeItem(primaryExpenseCategoryNodesMap, {
|
|
itemType: SankeyChartNodeItemType.Category,
|
|
id: item.primaryCategory.id,
|
|
name: item.primaryCategory.name,
|
|
nameId: item.primaryCategory.id,
|
|
displayOrders: [item.primaryCategory.displayOrder],
|
|
amount: item.amountInDefaultCurrency,
|
|
depth: 5
|
|
});
|
|
|
|
updateLinkItem(linksMap, {
|
|
sourceItemType: SankeyChartNodeItemType.Account,
|
|
sourceItemId: item.account.id,
|
|
source: expenseAccountNameId,
|
|
sourceName: item.account.name,
|
|
targetItemType: SankeyChartNodeItemType.Category,
|
|
targetItemId: item.category.id,
|
|
target: item.category.id,
|
|
targetName: item.category.name,
|
|
value: item.amountInDefaultCurrency
|
|
});
|
|
|
|
updateLinkItem(linksMap, {
|
|
sourceItemType: SankeyChartNodeItemType.Category,
|
|
sourceItemId: item.category.id,
|
|
source: item.category.id,
|
|
sourceName: item.category.name,
|
|
targetItemType: SankeyChartNodeItemType.Category,
|
|
targetItemId: item.primaryCategory.id,
|
|
target: item.primaryCategory.id,
|
|
targetName: item.primaryCategory.name,
|
|
value: item.amountInDefaultCurrency
|
|
});
|
|
} else if (item.primaryCategory.type == CategoryType.Transfer && item.relatedAccount) {
|
|
const relatedAccountNameId = `expense_account:${item.relatedAccount.id}`;
|
|
updateLinkItem(linksMap, {
|
|
sourceItemType: SankeyChartNodeItemType.Account,
|
|
sourceItemId: item.account.id,
|
|
source: incomeAccountNameId,
|
|
sourceName: item.account.name,
|
|
targetItemType: SankeyChartNodeItemType.Account,
|
|
targetItemId: item.relatedAccount.id,
|
|
target: relatedAccountNameId,
|
|
targetName: item.relatedAccount.name,
|
|
value: item.amountInDefaultCurrency
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const expenseAccountNode of values(expenseAccountNodesMap)) {
|
|
const incomeAccountNode = incomeAccountNodesMap[`income_account:${expenseAccountNode.itemId}`];
|
|
|
|
if (!incomeAccountNode) {
|
|
continue;
|
|
}
|
|
|
|
let totalOutflowAmount = 0;
|
|
let totalInflowAmount = 0;
|
|
|
|
for (const link of values(linksMap)) {
|
|
if (link.sourceItemType === SankeyChartNodeItemType.Account && link.sourceItemId === expenseAccountNode.itemId) {
|
|
totalOutflowAmount += link.value;
|
|
} else if (link.targetItemType === SankeyChartNodeItemType.Account && link.targetItemId === expenseAccountNode.itemId) {
|
|
totalInflowAmount += link.value;
|
|
}
|
|
}
|
|
|
|
const amountDifference = totalOutflowAmount - totalInflowAmount;
|
|
|
|
if (amountDifference > 0) {
|
|
updateLinkItem(linksMap, {
|
|
sourceItemType: SankeyChartNodeItemType.Account,
|
|
sourceItemId: incomeAccountNode.itemId,
|
|
source: incomeAccountNode.nameId,
|
|
sourceName: incomeAccountNode.displayName,
|
|
targetItemType: SankeyChartNodeItemType.Account,
|
|
targetItemId: expenseAccountNode.itemId,
|
|
target: expenseAccountNode.nameId,
|
|
targetName: expenseAccountNode.displayName,
|
|
value: amountDifference
|
|
});
|
|
}
|
|
}
|
|
|
|
const nodes: SankeyChartNodeItem[] = [];
|
|
const links: SankeyChartLinkItem[] = [];
|
|
addFinalSortedNodeItems(primaryIncomeCategoryNodesMap, nodes);
|
|
addFinalSortedNodeItems(secondaryIncomeCategoryNodesMap, nodes);
|
|
addFinalSortedNodeItems(incomeAccountNodesMap, nodes);
|
|
addFinalSortedNodeItems(expenseAccountNodesMap, nodes);
|
|
addFinalSortedNodeItems(secondaryExpenseCategoryNodesMap, nodes);
|
|
addFinalSortedNodeItems(primaryExpenseCategoryNodesMap, nodes);
|
|
addFinalLinkItems(linksMap, links);
|
|
|
|
const ret: SankeyChartData = {
|
|
nodes: nodes,
|
|
links: links
|
|
};
|
|
|
|
return ret;
|
|
});
|
|
|
|
const chartOptions = computed<object>(() => {
|
|
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, isDarkMode.value);
|
|
|
|
return {
|
|
tooltip: {
|
|
trigger: 'item',
|
|
backgroundColor: isDarkMode.value ? '#333' : '#fff',
|
|
borderColor: isDarkMode.value ? '#333' : '#fff',
|
|
textStyle: {
|
|
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;
|
|
return `<div><span>${dataItem.displayName}</span><span class="ms-5" style="float: inline-end">${displayValue}</span></div>`;
|
|
} else if (params.dataType === 'edge') {
|
|
const dataItem = params.data as SankeyChartLinkItem;
|
|
return `<div><span>${dataItem.sourceDisplayName} → ${dataItem.targetDisplayName}</span><span class="ms-5" style="float: inline-end">${displayValue}</span></div>`;
|
|
} else {
|
|
return '';
|
|
}
|
|
}
|
|
},
|
|
series: [
|
|
{
|
|
type: 'sankey',
|
|
left: 10,
|
|
top: 10,
|
|
bottom: 10,
|
|
roam: true,
|
|
label: {
|
|
formatter: (params: CallbackDataParams) => {
|
|
const dataItem = params.data as SankeyChartNodeItem;
|
|
return dataItem.displayName;
|
|
}
|
|
},
|
|
levels: [
|
|
{
|
|
depth: 0,
|
|
itemStyle: {
|
|
color: expenseIncomeAmountColor.incomeAmountColor,
|
|
opacity: 0.6
|
|
},
|
|
lineStyle: {
|
|
color: 'source',
|
|
opacity: 0.3
|
|
}
|
|
},
|
|
{
|
|
depth: 1,
|
|
itemStyle: {
|
|
color: expenseIncomeAmountColor.incomeAmountColor,
|
|
opacity: 0.4
|
|
},
|
|
lineStyle: {
|
|
color: 'source',
|
|
opacity: 0.2
|
|
}
|
|
},
|
|
{
|
|
depth: 2,
|
|
itemStyle: {
|
|
color: '#c07d43',
|
|
opacity: 0.5
|
|
},
|
|
lineStyle: {
|
|
color: 'source',
|
|
opacity: 0.2
|
|
}
|
|
},
|
|
{
|
|
depth: 3,
|
|
itemStyle: {
|
|
color: '#c07d43',
|
|
opacity: 0.5
|
|
},
|
|
lineStyle: {
|
|
color: 'source',
|
|
opacity: 0.2
|
|
}
|
|
},
|
|
{
|
|
depth: 4,
|
|
itemStyle: {
|
|
color: expenseIncomeAmountColor.expenseAmountColor,
|
|
opacity: 0.4
|
|
},
|
|
lineStyle: {
|
|
color: 'source',
|
|
opacity: 0.2
|
|
}
|
|
},
|
|
{
|
|
depth: 5,
|
|
itemStyle: {
|
|
color: expenseIncomeAmountColor.expenseAmountColor,
|
|
opacity: 0.6
|
|
},
|
|
lineStyle: {
|
|
color: 'source',
|
|
opacity: 0.3
|
|
}
|
|
}
|
|
],
|
|
emphasis: {
|
|
focus: 'adjacency'
|
|
},
|
|
data: sankeyData.value.nodes,
|
|
links: sankeyData.value.links,
|
|
animation: !props.skeleton
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
function updateNodeItem(nodesMap: Record<string, SankeyChartNodeItem>, { itemType, id, name, nameId, displayOrders, amount, depth }: { itemType: SankeyChartNodeItemType, id: string, name: string, nameId: string, displayOrders: number[], amount: number, depth: number }) {
|
|
const node: SankeyChartNodeItem | undefined = nodesMap[nameId];
|
|
|
|
if (!node) {
|
|
nodesMap[nameId] = {
|
|
itemType: itemType,
|
|
itemId: id,
|
|
name: name,
|
|
nameId: nameId,
|
|
displayName: name,
|
|
displayOrders: displayOrders,
|
|
totalAmount: amount,
|
|
depth: depth
|
|
};
|
|
} else {
|
|
node.totalAmount += amount;
|
|
}
|
|
}
|
|
|
|
function updateLinkItem(linksMap: Record<string, SankeyChartLinkItem>, { sourceItemType, sourceItemId, source, sourceName, targetItemType, targetItemId, target, targetName, value }: { sourceItemType: SankeyChartNodeItemType, sourceItemId: string, source: string, sourceName: string, targetItemType: SankeyChartNodeItemType, targetItemId: string, target: string, targetName: string, value: number }) {
|
|
const key = `${source}:${target}`;
|
|
const link: SankeyChartLinkItem | undefined = linksMap[key];
|
|
|
|
if (!link) {
|
|
linksMap[key] = {
|
|
sourceItemType: sourceItemType,
|
|
sourceItemId: sourceItemId,
|
|
source: source,
|
|
sourceDisplayName: sourceName,
|
|
targetItemType: targetItemType,
|
|
targetItemId: targetItemId,
|
|
target: target,
|
|
targetDisplayName: targetName,
|
|
value: value
|
|
};
|
|
} else {
|
|
link.value += value;
|
|
}
|
|
}
|
|
|
|
function addFinalSortedNodeItems(nodesMap: Record<string, SankeyChartNodeItem>, allNodesArray: SankeyChartNodeItem[]): void {
|
|
const nodesArray: SankeyChartNodeItem[] = [];
|
|
|
|
for (const node of values(nodesMap)) {
|
|
nodesArray.push(node);
|
|
}
|
|
|
|
sortStatisticsItems(nodesArray, props.sortingType);
|
|
|
|
for (const node of nodesArray) {
|
|
node.name = node.nameId;
|
|
}
|
|
|
|
allNodesArray.push(...nodesArray);
|
|
}
|
|
|
|
function addFinalLinkItems(linksMap: Record<string, SankeyChartLinkItem>, allLinksArray: SankeyChartLinkItem[]): void {
|
|
const linksArray: SankeyChartLinkItem[] = [];
|
|
|
|
for (const link of values(linksMap)) {
|
|
linksArray.push(link);
|
|
}
|
|
|
|
allLinksArray.push(...linksArray);
|
|
}
|
|
|
|
function clickItem(e: ECElementEvent): void {
|
|
if (!props.enableClickItem || e.componentType !== 'series' || e.seriesType !=='sankey') {
|
|
return;
|
|
}
|
|
|
|
if (!e.data) {
|
|
return;
|
|
}
|
|
|
|
if (e.dataType === 'node') {
|
|
const dataItem = e.data as SankeyChartNodeItem;
|
|
emit('click', dataItem.itemType, dataItem.itemId);
|
|
} else if (e.dataType === 'edge') {
|
|
const dataItem = e.data as SankeyChartLinkItem;
|
|
|
|
if (dataItem.sourceItemType === dataItem.targetItemType && dataItem.sourceItemId === dataItem.targetItemId) {
|
|
emit('click', dataItem.sourceItemType, dataItem.sourceItemId);
|
|
} else {
|
|
emit('click', dataItem.sourceItemType, dataItem.sourceItemId, dataItem.targetItemType, dataItem.targetItemId);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.account-category-sankey-chart-container {
|
|
width: 100%;
|
|
height: 460px;
|
|
}
|
|
|
|
@media (min-width: 600px) {
|
|
.account-category-sankey-chart-container {
|
|
height: 660px;
|
|
}
|
|
}
|
|
|
|
.account-category-sankey-chart-container.transition-in {
|
|
animation: radar-chart-skeleton-fade-in 2s 1;
|
|
}
|
|
|
|
@keyframes radar-chart-skeleton-fade-in {
|
|
0% {
|
|
opacity: 0;
|
|
}
|
|
20% {
|
|
opacity: 0;
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|