Clean up sankey.

This commit is contained in:
James Cole
2025-07-30 11:24:00 +02:00
parent 1a633e64ef
commit ea0ced70b2
2 changed files with 246 additions and 201 deletions

View File

@@ -29,17 +29,16 @@ import i18next from "i18next";
Chart.register({SankeyController, Flow}); Chart.register({SankeyController, Flow});
const SANKEY_CACHE_KEY = 'ds_sankey_data'; const SANKEY_CACHE_KEY = 'ds_sankey_data';
let currencies = []; let currencies = [];
let afterPromises = false; let afterPromises = false;
let chart = null; let chart = null;
let transactions = []; let transactions = [];
let convertToNative = false; let convertToNative = false;
let translations = { let translations = {
category: null, category: null,
unknown_category: null, unknown_category: null,
in: null, in: null,
out: null, out: null,
// TODO
unknown_source: null, unknown_source: null,
unknown_dest: null, unknown_dest: null,
unknown_account: null, unknown_account: null,
@@ -80,75 +79,97 @@ const getColor = function (key) {
// little helper // little helper
function getObjectName(type, name, direction, code) { function getObjectName(type, name, direction, code) {
if(convertToNative) {
return getObjectNameWithoutCurrency(type, name, direction);
}
return getObjectNameWithCurrency(type, name, direction, code);
// category 4x
if ('category' === type && null !== name && 'in' === direction) {
return translations.category + ' "' + name + '" (' + translations.in + (convertToNative ? ', ' + code + ')' : ')');
}
if ('category' === type && null === name && 'in' === direction) {
return translations.unknown_category + ' (' + translations.in + (convertToNative ? ', ' + code + ')' : ')');
}
if ('category' === type && null !== name && 'out' === direction) {
return translations.category + ' "' + name + '" (' + translations.out + (convertToNative ? ', ' + code + ')' : ')');
}
if ('category' === type && null === name && 'out' === direction) {
return translations.unknown_category + ' (' + translations.out + (convertToNative ? ', ' + code + ')' : ')');
}
// account 4x
if ('account' === type && null === name && 'in' === direction) {
return translations.unknown_source + (convertToNative ? ' (' + code + ')' : '');
}
if ('account' === type && null !== name && 'in' === direction) {
return translations.revenue_account + '"' + name + '"' + (convertToNative ? ' (' + code + ')' : '');
}
if ('account' === type && null === name && 'out' === direction) {
return translations.unknown_dest + (convertToNative ? ' (' + code + ')' : '');
}
if ('account' === type && null !== name && 'out' === direction) {
return translations.expense_account + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : '');
}
// budget 2x
if ('budget' === type && null !== name) {
return translations.budget + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : '');
}
if ('budget' === type && null === name) {
return translations.unknown_budget + (convertToNative ? ' (' + code + ')' : '');
}
console.error('Cannot handle: type:"' + type + '", dir: "' + direction + '"');
} }
function getLabelName(type, name, code) { function getObjectNameWithoutCurrency(type, name, direction) {
// category if('category' === type) {
if ('category' === type && null !== name) { let catName = null === name ? translations.unknown_category : translations.category + ' "' + name + '"';
return translations.category + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : ''); let directionText = 'in' === direction ? translations.in : translations.out;
return catName + ' (' + directionText + ')';
} }
if ('category' === type && null === name) { if('account' === type) {
return translations.unknown_category + (convertToNative ? ' (' + code + ')' : ''); let accountName = null === name ? translations.unknown_account : name;
let directionText = 'in' === direction ? translations.in : translations.out;
let fullAccountName = 'in' === direction ? translations.revenue_account + ' "' + accountName + '"' : translations.expense_account + ' "' + accountName + '"';
return fullAccountName + ' (' + directionText + ')';
} }
// account if('budget' === type) {
if ('account' === type && null === name) { return null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"';
return translations.unknown_account + (convertToNative ? ' (' + code + ')' : '');
} }
if ('account' === type && null !== name) { console.error('[a] Cannot handle: type:"' + type + '", dir: "' + direction + '"');
return name + (convertToNative ? ' (' + code + ')' : ''); }
function getObjectNameWithCurrency(type, name, direction, code) {
if('category' === type) {
let catName = null === name ? translations.unknown_category : translations.category + ' "' + name + '"';
let directionText = 'in' === direction ? translations.in : translations.out;
return catName + ' (' + directionText + ', ' + code + ')';
} }
if('account' === type) {
// budget 2x let accountName = null === name ? translations.unknown_account : name;
if ('budget' === type && null !== name) { let directionText = 'in' === direction ? translations.in : translations.out;
return translations.budget + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : ''); let fullAccountName = 'in' === direction ? translations.revenue_account + ' "' + accountName + '"' : translations.expense_account + ' "' + accountName + '"';
return fullAccountName + ' (' + directionText + ', ' + code + ')';
} }
if ('budget' === type && null === name) { if('budget' === type) {
return translations.unknown_budget + (convertToNative ? ' (' + code + ')' : ''); return (null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"') + ' (' + code + ')';
} }
console.error('Cannot handle: type:"' + type + '"'); console.error('[b] Cannot handle: type:"' + type + '", dir: "' + direction + '"');
} }
function getLabel(type, name, code) {
if(convertToNative) {
return getLabelWithoutCurrency(type, name);
}
return getLabelWithCurrency(type, name, code);
}
function getLabelWithoutCurrency(type, name) {
if('category' === type) {
return null === name ? translations.unknown_category : translations.category + ' "' + name + '"';
}
if('account' === type) {
return null === name ? translations.unknown_account : name;
}
if('budget' === type) {
return null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"';
}
console.error('[a] Cannot handle: type:"' + type + '"');
}
function getLabelWithCurrency(type, name, code) {
if('category' === type) {
return (null === name ? translations.unknown_category : translations.category + ' "' + name + '"') + ' ('+ code + ')';
}
if('account' === type) {
return (null === name ? translations.unknown_account : name) + ' (' + code + ')';
}
if('budget' === type) {
return (null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"') + ' (' + code + ')';;
}
console.error('[b] Cannot handle: type:"' + type + '"');
}
export default () => ({ export default () => ({
loading: false, loading: false,
convertToNative: false, convertToNative: false,
processedData: null,
eventListeners: {
['@convert-to-native.window'](event){
console.log('I heard that! (dashboard/sankey)');
this.convertToNative = event.detail;
convertToNative = event.detail;
this.processedData = null;
this.loadChart();
}
},
generateOptions() { generateOptions() {
let options = getDefaultChartSettings('sankey'); let options = getDefaultChartSettings('sankey');
@@ -156,125 +177,22 @@ export default () => ({
currencies = []; currencies = [];
// variables collected for the sankey chart: // variables collected for the sankey chart:
let amounts = {}; this.parseTransactionGroups(transactions);
let labels = {};
for (let i in transactions) {
if (transactions.hasOwnProperty(i)) {
let group = transactions[i];
for (let ii in group.attributes.transactions) {
if (group.attributes.transactions.hasOwnProperty(ii)) {
// properties of the transaction, used in the generation of the chart:
let transaction = group.attributes.transactions[ii];
let currencyCode = this.convertToNative ? transaction.native_currency_code : transaction.currency_code;
if(this.convertToNative && (!transaction.hasOwnProperty('native_amount') || null === transaction.native_amount)) {
// skip this transaction, it has no native amount.
console.error('No native amount for transaction #' + group.id + ' ('+this.convertToNative+')');
continue;
}
let amount = this.convertToNative ? parseFloat(transaction.native_amount) : parseFloat(transaction.amount);
let flowKey;
/*
Two entries in the sankey diagram for deposits:
1. From the revenue account (source) to a category (in).
2. From the category (in) to the big inbox.
*/
if ('deposit' === transaction.type) {
// nr 1
let category = getObjectName('category', transaction.category_name, 'in', currencyCode);
let revenueAccount = getObjectName('account', transaction.source_name, 'in', currencyCode);
labels[category] = getLabelName('category', transaction.category_name, currencyCode);
labels[revenueAccount] = getLabelName('account', transaction.source_name, currencyCode);
flowKey = revenueAccount + '-' + category + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: revenueAccount,
to: category,
amount: 0
};
}
amounts[flowKey].amount += amount;
// nr 2
flowKey = category + '-' + translations.all_money + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: category,
to: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
amount: 0
};
}
amounts[flowKey].amount += amount;
}
/*
Three entries in the sankey diagram for withdrawals:
1. From the big box to a budget.
2. From a budget to a category.
3. From a category to an expense account.
*/
if ('withdrawal' === transaction.type) {
// 1.
let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode);
labels[budget] = getLabelName('budget', transaction.budget_name, currencyCode);
flowKey = translations.all_money + '-' + budget + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
to: budget,
amount: 0
};
}
amounts[flowKey].amount += amount;
// 2.
let category = getObjectName('category', transaction.category_name, 'out', currencyCode);
labels[category] = getLabelName('category', transaction.category_name, currencyCode);
flowKey = budget + '-' + category + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: budget,
to: category,
amount: 0
};
}
amounts[flowKey].amount += amount;
// 3.
let expenseAccount = getObjectName('account', transaction.destination_name, 'out', currencyCode);
labels[expenseAccount] = getLabelName('account', transaction.destination_name, currencyCode);
flowKey = category + '-' + expenseAccount + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: category,
to: expenseAccount,
amount: 0
};
}
amounts[flowKey].amount += amount;
}
}
}
}
}
let dataSet = let dataSet =
// sankey chart has one data set. // sankey chart has one data set.
{ {
label: 'Firefly III dashboard sankey chart', label: 'Firefly III dashboard sankey chart',
data: [], data: [],
colorFrom: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].from : ''), colorFrom: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].from : ''),
colorTo: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].to : ''), colorTo: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].to : ''),
colorMode: 'gradient', // or 'from' or 'to' colorMode: 'gradient', // or 'from' or 'to'
labels: labels, labels: this.processedData.labels,
size: 'min', // or 'min' if flow overlap is preferred size: 'min', // or 'min' if flow overlap is preferred
}; };
for (let i in amounts) { for (let i in this.processedData.amounts) {
if (amounts.hasOwnProperty(i)) { if (this.processedData.amounts.hasOwnProperty(i)) {
let amount = amounts[i]; let amount = this.processedData.amounts[i];
dataSet.data.push({from: amount.from, to: amount.to, flow: amount.amount}); dataSet.data.push({from: amount.from, to: amount.to, flow: amount.amount});
} }
} }
@@ -282,6 +200,133 @@ export default () => ({
return options; return options;
}, },
parseTransactionGroups(groups) {
this.processedData = {
amounts: {},
labels: {}
};
for (let i in groups) {
if (groups.hasOwnProperty(i)) {
let group = groups[i];
this.parseTransactionGroup(group);
}
}
},
parseTransactionGroup(group) {
for (let ii in group.attributes.transactions) {
if (group.attributes.transactions.hasOwnProperty(ii)) {
// properties of the transaction, used in the generation of the chart:
let transaction = group.attributes.transactions[ii];
this.parseTransaction(transaction);
}
}
},
parseTransaction(transaction) {
let currencyCode = transaction.currency_code;
let amount = parseFloat(transaction.amount);
let flowKey;
if (this.convertToNative) {
currencyCode = transaction.native_currency_code;
amount = parseFloat(transaction.native_amount);
}
if ('deposit' === transaction.type) {
this.parseDeposit(transaction, currencyCode, amount);
return;
}
if ('withdrawal' === transaction.type) {
this.parseWithdrawal(transaction, currencyCode, amount);
}
},
parseWithdrawal(transaction, currencyCode, amount) {
/*
Three entries in the sankey diagram for withdrawals:
1. From the big box to a budget.
2. From a budget to a category.
3. From a category to an expense account.
*/
// first one:
let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode);
this.processedData.labels[budget] = getLabel('budget', transaction.budget_name, currencyCode);
let flowKey = translations.all_money + '-' + budget + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
to: budget,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
// second one:
let category = getObjectName('category', transaction.category_name, 'out', currencyCode);
this.processedData.labels[category] = getLabel('category', transaction.category_name, currencyCode);
flowKey = budget + '-' + category + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: budget,
to: category,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
// third one:
let expenseAccount = getObjectName('account', transaction.destination_name, 'out', currencyCode);
this.processedData.labels[expenseAccount] = getLabel('account', transaction.destination_name, currencyCode);
flowKey = category + '-' + expenseAccount + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: category,
to: expenseAccount,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
},
parseDeposit(transaction, currencyCode, amount) {
/*
Two entries in the sankey diagram for deposits:
1. From the revenue account (source) to a category (in).
2. From the category (in) to the big inbox.
*/
// this is the first one:
let category = getObjectName('category', transaction.category_name, 'in', currencyCode);
let revenueAccount = getObjectName('account', transaction.source_name, 'in', currencyCode);
let flowKey = revenueAccount + '-' + category + '-' + currencyCode;
this.processedData.labels[category] = getLabel('category', transaction.category_name, currencyCode);
this.processedData.labels[revenueAccount] = getLabel('account', transaction.source_name, currencyCode);
// create if necessary:
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: revenueAccount,
to: category,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
// this is the second one:
flowKey = category + '-' + translations.all_money + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: category,
to: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
},
drawChart(options) { drawChart(options) {
if (null !== chart) { if (null !== chart) {
chart.data.datasets = options.data.datasets; chart.data.datasets = options.data.datasets;
@@ -292,12 +337,12 @@ export default () => ({
}, },
getFreshData() { getFreshData() {
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {start: start, end: end}); const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {start: start, end: end});
const cacheValid = window.store.get('cacheValid'); const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey); let cachedData = window.store.get(cacheKey);
if (cacheValid && typeof cachedData !== 'undefined') { if (cacheValid && typeof cachedData !== 'undefined') {
transactions = cachedData; transactions = cachedData;
@@ -316,8 +361,8 @@ export default () => ({
this.downloadTransactions(params); this.downloadTransactions(params);
}, },
downloadTransactions(params) { downloadTransactions(params) {
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {convertToNative: this.convertToNative, start: start, end: end}); const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {convertToNative: this.convertToNative, start: start, end: end});
//console.log('Downloading page ' + params.page + '...'); //console.log('Downloading page ' + params.page + '...');
@@ -356,25 +401,25 @@ export default () => ({
transactions = []; transactions = [];
Promise.all([getVariable('convert_to_native', false)]).then((values) => { Promise.all([getVariable('convert_to_native', false)]).then((values) => {
this.convertToNative = values[0]; this.convertToNative = values[0];
convertToNative = values[0]; convertToNative = values[0];
// some translations: // some translations:
translations.all_money = i18next.t('firefly.all_money'); translations.all_money = i18next.t('firefly.all_money');
translations.category = i18next.t('firefly.category'); translations.category = i18next.t('firefly.category');
translations.in = i18next.t('firefly.money_flowing_in'); translations.in = i18next.t('firefly.money_flowing_in');
translations.out = i18next.t('firefly.money_flowing_out'); translations.out = i18next.t('firefly.money_flowing_out');
translations.unknown_category = i18next.t('firefly.unknown_category_plain'); translations.unknown_category = i18next.t('firefly.unknown_category_plain');
translations.unknown_source = i18next.t('firefly.unknown_source_plain'); translations.unknown_source = i18next.t('firefly.unknown_source_plain');
translations.unknown_dest = i18next.t('firefly.unknown_dest_plain'); translations.unknown_dest = i18next.t('firefly.unknown_dest_plain');
translations.unknown_account = i18next.t('firefly.unknown_any_plain'); translations.unknown_account = i18next.t('firefly.unknown_any_plain');
translations.unknown_budget = i18next.t('firefly.unknown_budget_plain'); translations.unknown_budget = i18next.t('firefly.unknown_budget_plain');
translations.expense_account = i18next.t('firefly.expense_account'); translations.expense_account = i18next.t('firefly.expense_account');
translations.revenue_account = i18next.t('firefly.revenue_account'); translations.revenue_account = i18next.t('firefly.revenue_account');
translations.budget = i18next.t('firefly.budget'); translations.budget = i18next.t('firefly.budget');
// console.log('sankey after promises'); // console.log('sankey after promises');
afterPromises = true; afterPromises = true;
this.loadChart(); this.loadChart();
}); });
window.store.observe('end', () => { window.store.observe('end', () => {

View File

@@ -6,7 +6,7 @@
>{{ __('firefly.income_and_expense') }}</a> >{{ __('firefly.income_and_expense') }}</a>
</h3> </h3>
</div> </div>
<div class="card-body" x-data="sankey"> <div class="card-body" x-data="sankey" x-bind="eventListeners">
<canvas id="sankey-chart"></canvas> <canvas id="sankey-chart"></canvas>
</div> </div>
</div> </div>