feat(insights): sankey diagram (WIP)

This commit is contained in:
Herculino Trotta
2025-02-11 00:37:30 -03:00
parent b53a4a0286
commit 02376ad02b
6 changed files with 274 additions and 1 deletions

View File

@@ -49,4 +49,5 @@ urlpatterns = [
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
path("", include("apps.insights.urls")),
]

View File

@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("insights/sankey/", views.sankey, name="sankey"),
]

View File

View File

@@ -0,0 +1,102 @@
from django.db.models import Sum
from django.utils.translation import gettext_lazy as _
from decimal import Decimal
from typing import Dict, List, TypedDict
class SankeyNode(TypedDict):
name: str
class SankeyFlow(TypedDict):
from_node: str
to_node: str
flow: float
currency: Dict
original_amount: float
percentage: float
def generate_sankey_data(transactions_queryset):
"""
Generates Sankey diagram data from transaction queryset.
Uses a 1-5 scale for flows based on percentages.
"""
nodes: Dict[str, SankeyNode] = {}
flows: List[SankeyFlow] = []
# Aggregate transactions
income_data = {} # {(category, currency, account) -> amount}
expense_data = {} # {(category, currency, account) -> amount}
total_amount = Decimal("0")
for transaction in transactions_queryset:
currency = transaction.account.currency
account = transaction.account
category = transaction.category or _("Uncategorized")
key = (category, currency, account)
if transaction.type == "IN":
income_data[key] = income_data.get(key, Decimal("0")) + transaction.amount
else:
expense_data[key] = expense_data.get(key, Decimal("0")) + transaction.amount
total_amount += transaction.amount
# Function to add flow
def add_flow(from_node, to_node, amount, currency):
percentage = (amount / total_amount) * 100 if total_amount else 0
scaled_flow = 1 + min(percentage / 5, 2) # Scale 1-5, capping at 100%
flows.append(
{
"from_node": from_node,
"to_node": to_node,
"flow": float(scaled_flow),
"currency": {
"code": currency.code,
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimal_places": currency.decimal_places,
},
"original_amount": float(amount),
"percentage": float(percentage),
}
)
nodes[from_node] = {"name": from_node}
nodes[to_node] = {"name": to_node}
# Process income
for (category, currency, account), amount in income_data.items():
category_name = f"{category} ({currency.code})"
account_name = f"{account.name} ({currency.code})"
add_flow(category_name, account_name, amount, currency)
# Process expenses
for (category, currency, account), amount in expense_data.items():
category_name = f"{category} ({currency.code})"
account_name = f"{account.name} ({currency.code})"
add_flow(account_name, category_name, amount, currency)
# Calculate and add savings flows
savings_data = {} # {(account, currency) -> amount}
for (category, currency, account), amount in income_data.items():
key = (account, currency)
savings_data[key] = savings_data.get(key, Decimal("0")) + amount
for (category, currency, account), amount in expense_data.items():
key = (account, currency)
savings_data[key] = savings_data.get(key, Decimal("0")) - amount
for (account, currency), amount in savings_data.items():
if amount > 0:
account_name = f"{account.name} ({currency.code})"
savings_name = f"{_('Savings')} ({currency.code})"
add_flow(account_name, savings_name, amount, currency)
return {
"nodes": list(nodes.values()),
"flows": flows,
"total_amount": float(total_amount),
}

View File

@@ -1,3 +1,17 @@
from django.shortcuts import render
# Create your views here.
from apps.transactions.models import Transaction
from apps.insights.utils.sankey import generate_sankey_data
def sankey(request):
# Get filtered transactions
transactions = Transaction.objects.filter(date__year=2025)
# Generate Sankey data
sankey_data = generate_sankey_data(transactions)
print(sankey_data)
return render(
request, "insights/fragments/sankey.html", {"sankey_data": sankey_data}
)

View File

@@ -0,0 +1,149 @@
{% extends "layouts/base.html" %}
{% block content %}
<canvas id="sankeyChart" class="h-100"></canvas>
<script>
function setupSankeyChart(data, chartId = 'sankeyChart') {
// Color generation function
function generateColors(nodes) {
const colorMap = {};
const incomeColor = '#4CAF50'; // Green
const expenseColor = '#F44336'; // Red
const savingsColor = '#4CAF50'; // Green (same as income)
const accountColor = '#2196F3'; // Blue
nodes.forEach((node) => {
if (node.name.includes('(')) {
const [category, currency] = node.name.split(' (');
if (category.toLowerCase() === 'savings') {
colorMap[node.name] = savingsColor;
} else if (data.flows.some(flow => flow.from_node === node.name)) {
colorMap[node.name] = incomeColor;
} else if (data.flows.some(flow => flow.to_node === node.name)) {
colorMap[node.name] = expenseColor;
} else {
colorMap[node.name] = accountColor;
}
} else {
colorMap[node.name] = accountColor;
}
});
return colorMap;
}
// Format currency value
function formatCurrency(value, currency) {
return new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: currency.decimal_places,
maximumFractionDigits: currency.decimal_places
}).format(value);
}
// Generate colors for nodes
const colorMap = generateColors(data.nodes);
// Create a mapping of node names to indices
const nodeIndices = {};
const nodeSizes = {};
data.nodes.forEach((node, index) => {
nodeIndices[node.name] = index;
nodeSizes[node.name] = node.size;
});
console.log(data.flows.map(flow => ({
from: nodeIndices[flow.from_node],
to: nodeIndices[flow.to_node],
flow: flow.flow
})),);
console.log(nodeSizes)
// Format data for Chart.js
const chartData = {
datasets: [{
data: data.flows.map(flow => ({
from: nodeIndices[flow.from_node],
to: nodeIndices[flow.to_node],
flow: flow.flow
})),
colorFrom: (c) => colorMap[data.nodes[c.dataset.data[c.dataIndex].from].name],
colorTo: (c) => colorMap[data.nodes[c.dataset.data[c.dataIndex].to].name],
colorMode: 'to',
labels: data.nodes.map(node => node.name),
size: 'max',
}]
};
// Chart configuration
const config = {
type: 'sankey',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const flow = data.flows[context.dataIndex];
const formattedValue = formatCurrency(flow.original_amount, flow.currency);
return [
`De: ${flow.from_node}`,
`Para: ${flow.to_node}`,
`Valor: ${flow.currency.prefix}${formattedValue}${flow.currency.suffix}`,
`Porcentagem: ${flow.percentage.toFixed(2)}%`
];
}
}
},
legend: {
display: false
},
title: {
display: true,
text: 'Fluxo de Transações',
font: {
size: 16
}
}
},
layout: {
padding: {
top: 20,
right: 20,
bottom: 20,
left: 20
}
}
}
};
// Destroy existing chart if it exists
const existingChart = Chart.getChart(chartId);
if (existingChart) {
existingChart.destroy();
}
// Create new chart
return new Chart(
document.getElementById(chartId),
config
);
}
// Usage in Django template or JavaScript file
document.addEventListener('DOMContentLoaded', function() {
// Assuming you have the Sankey data in a variable called sankeyData
// For Django template:
const sankeyData = {{ sankey_data|safe }};
console.log(sankeyData);
const chart = setupSankeyChart(sankeyData);
// Optional: Handle resize
window.addEventListener('resize', () => {
chart.resize();
});
});
</script>
{% endblock %}