mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-24 17:48:41 +02:00
feat(insights): sankey diagram (WIP)
This commit is contained in:
@@ -49,4 +49,5 @@ urlpatterns = [
|
|||||||
path("", include("apps.dca.urls")),
|
path("", include("apps.dca.urls")),
|
||||||
path("", include("apps.mini_tools.urls")),
|
path("", include("apps.mini_tools.urls")),
|
||||||
path("", include("apps.import_app.urls")),
|
path("", include("apps.import_app.urls")),
|
||||||
|
path("", include("apps.insights.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
7
app/apps/insights/urls.py
Normal file
7
app/apps/insights/urls.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("insights/sankey/", views.sankey, name="sankey"),
|
||||||
|
]
|
||||||
0
app/apps/insights/utils/__init__.py
Normal file
0
app/apps/insights/utils/__init__.py
Normal file
102
app/apps/insights/utils/sankey.py
Normal file
102
app/apps/insights/utils/sankey.py
Normal 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),
|
||||||
|
}
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
from django.shortcuts import render
|
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}
|
||||||
|
)
|
||||||
|
|||||||
149
app/templates/insights/fragments/sankey.html
Normal file
149
app/templates/insights/fragments/sankey.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user