mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Sankey Diagram (#2269)
* Enhance cash flow dashboard with new cash flow period handling and improved Sankey diagram rendering. Update D3 and related dependencies for better performance and features. * Fix Rubocop offenses * Refactor Sankey chart controller to use Number.parseFloat for value formatting and improve code readability by restructuring conditional logic for node shapes.
This commit is contained in:
parent
caf35701ef
commit
868d4ede6e
44 changed files with 451 additions and 20 deletions
|
@ -6,6 +6,23 @@ class PagesController < ApplicationController
|
|||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
|
||||
period_param = params[:cashflow_period]
|
||||
@cashflow_period = if period_param.present?
|
||||
begin
|
||||
Period.from_key(period_param)
|
||||
rescue Period::InvalidKeyError
|
||||
Period.last_30_days
|
||||
end
|
||||
else
|
||||
Period.last_30_days
|
||||
end
|
||||
|
||||
family_currency = Current.family.currency
|
||||
income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
|
||||
expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
|
||||
|
||||
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
|
||||
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
|
||||
|
@ -31,4 +48,98 @@ class PagesController < ApplicationController
|
|||
def github_provider
|
||||
Provider::Registry.get_provider(:github)
|
||||
end
|
||||
|
||||
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
|
||||
nodes = []
|
||||
links = []
|
||||
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
|
||||
|
||||
# Helper to add/find node and return its index
|
||||
add_node = ->(unique_key, display_name, value, percentage, color) {
|
||||
node_indices[unique_key] ||= begin
|
||||
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
|
||||
nodes.size - 1
|
||||
end
|
||||
}
|
||||
|
||||
total_income_val = income_totals.total.to_f.round(2)
|
||||
total_expense_val = expense_totals.total.to_f.round(2)
|
||||
|
||||
# --- Create Central Cash Flow Node ---
|
||||
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
|
||||
|
||||
# --- Process Income Side ---
|
||||
income_category_values = Hash.new(0.0)
|
||||
income_totals.category_totals.each do |ct|
|
||||
val = ct.total.to_f.round(2)
|
||||
next if val.zero? || !ct.category.parent_id
|
||||
income_category_values[ct.category.parent_id] += val
|
||||
end
|
||||
|
||||
income_totals.category_totals.each do |ct|
|
||||
val = ct.total.to_f.round(2)
|
||||
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
|
||||
next if val.zero?
|
||||
|
||||
node_display_name = ct.category.name
|
||||
node_value_for_label = val + income_category_values[ct.category.id] # This sum is for parent node display
|
||||
node_percentage_for_label = total_income_val.zero? ? 0 : (node_value_for_label / total_income_val * 100).round(1)
|
||||
|
||||
node_color = ct.category.color.presence || Category::COLORS.sample
|
||||
current_cat_idx = add_node.call("income_#{ct.category.id}", node_display_name, node_value_for_label, node_percentage_for_label, node_color)
|
||||
|
||||
if ct.category.parent_id
|
||||
parent_cat_idx = node_indices["income_#{ct.category.parent_id}"]
|
||||
parent_cat_idx ||= add_node.call("income_#{ct.category.parent.id}", ct.category.parent.name, income_category_values[ct.category.parent.id], 0, ct.category.parent.color || Category::COLORS.sample) # Parent percentage will be recalc based on its total flow
|
||||
links << { source: current_cat_idx, target: parent_cat_idx, value: val, color: node_color, percentage: percentage_of_total_income }
|
||||
else
|
||||
links << { source: current_cat_idx, target: cash_flow_idx, value: val, color: node_color, percentage: percentage_of_total_income }
|
||||
end
|
||||
end
|
||||
|
||||
# --- Process Expense Side ---
|
||||
expense_category_values = Hash.new(0.0)
|
||||
expense_totals.category_totals.each do |ct|
|
||||
val = ct.total.to_f.round(2)
|
||||
next if val.zero? || !ct.category.parent_id
|
||||
expense_category_values[ct.category.parent_id] += val
|
||||
end
|
||||
|
||||
expense_totals.category_totals.each do |ct|
|
||||
val = ct.total.to_f.round(2)
|
||||
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
|
||||
next if val.zero?
|
||||
|
||||
node_display_name = ct.category.name
|
||||
node_value_for_label = val + expense_category_values[ct.category.id]
|
||||
node_percentage_for_label = total_expense_val.zero? ? 0 : (node_value_for_label / total_expense_val * 100).round(1) # Percentage relative to total expenses for expense nodes
|
||||
|
||||
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
||||
current_cat_idx = add_node.call("expense_#{ct.category.id}", node_display_name, node_value_for_label, node_percentage_for_label, node_color)
|
||||
|
||||
if ct.category.parent_id
|
||||
parent_cat_idx = node_indices["expense_#{ct.category.parent_id}"]
|
||||
parent_cat_idx ||= add_node.call("expense_#{ct.category.parent.id}", ct.category.parent.name, expense_category_values[ct.category.parent.id], 0, ct.category.parent.color || Category::UNCATEGORIZED_COLOR)
|
||||
links << { source: parent_cat_idx, target: current_cat_idx, value: val, color: nodes[parent_cat_idx][:color], percentage: percentage_of_total_expense }
|
||||
else
|
||||
links << { source: cash_flow_idx, target: current_cat_idx, value: val, color: node_color, percentage: percentage_of_total_expense }
|
||||
end
|
||||
end
|
||||
|
||||
# --- Process Surplus ---
|
||||
leftover = (total_income_val - total_expense_val).round(2)
|
||||
if leftover.positive?
|
||||
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
|
||||
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
|
||||
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
|
||||
end
|
||||
|
||||
# Update Cash Flow and Income node percentages (relative to total income)
|
||||
if node_indices["cash_flow_node"]
|
||||
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
|
||||
end
|
||||
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
|
||||
|
||||
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
|
||||
end
|
||||
end
|
||||
|
|
204
app/javascript/controllers/sankey_chart_controller.js
Normal file
204
app/javascript/controllers/sankey_chart_controller.js
Normal file
|
@ -0,0 +1,204 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
|
||||
|
||||
// Connects to data-controller="sankey-chart"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Object,
|
||||
nodeWidth: { type: Number, default: 15 },
|
||||
nodePadding: { type: Number, default: 20 },
|
||||
currencySymbol: { type: String, default: "$" }
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.resizeObserver = new ResizeObserver(() => this.#draw());
|
||||
this.resizeObserver.observe(this.element);
|
||||
this.#draw();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const { nodes = [], links = [] } = this.dataValue || {};
|
||||
|
||||
if (!nodes.length || !links.length) return;
|
||||
|
||||
// Clear previous SVG
|
||||
d3.select(this.element).selectAll("svg").remove();
|
||||
|
||||
const width = this.element.clientWidth || 600;
|
||||
const height = this.element.clientHeight || 400;
|
||||
|
||||
const svg = d3
|
||||
.select(this.element)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
|
||||
const sankeyGenerator = sankey()
|
||||
.nodeWidth(this.nodeWidthValue)
|
||||
.nodePadding(this.nodePaddingValue)
|
||||
.extent([
|
||||
[16, 16],
|
||||
[width - 16, height - 16],
|
||||
]);
|
||||
|
||||
const sankeyData = sankeyGenerator({
|
||||
nodes: nodes.map((d) => Object.assign({}, d)),
|
||||
links: links.map((d) => Object.assign({}, d)),
|
||||
});
|
||||
|
||||
// Define gradients for links
|
||||
const defs = svg.append("defs");
|
||||
|
||||
sankeyData.links.forEach((link, i) => {
|
||||
const gradientId = `link-gradient-${link.source.index}-${link.target.index}-${i}`;
|
||||
|
||||
const getStopColorWithOpacity = (nodeColorInput, opacity = 0.1) => {
|
||||
let colorStr = nodeColorInput || "var(--color-gray-400)";
|
||||
if (colorStr === "var(--color-success)") {
|
||||
colorStr = "#10A861"; // Hex for --color-green-600
|
||||
}
|
||||
// Add other CSS var to hex mappings here if needed
|
||||
|
||||
if (colorStr.startsWith("var(--")) { // Unmapped CSS var, use as is (likely solid)
|
||||
return colorStr;
|
||||
}
|
||||
|
||||
const d3Color = d3.color(colorStr);
|
||||
return d3Color ? d3Color.copy({ opacity: opacity }) : "var(--color-gray-400)";
|
||||
};
|
||||
|
||||
const sourceStopColor = getStopColorWithOpacity(link.source.color);
|
||||
const targetStopColor = getStopColorWithOpacity(link.target.color);
|
||||
|
||||
const gradient = defs.append("linearGradient")
|
||||
.attr("id", gradientId)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("x1", link.source.x1)
|
||||
.attr("x2", link.target.x0);
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", sourceStopColor);
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", targetStopColor);
|
||||
});
|
||||
|
||||
// Draw links
|
||||
svg
|
||||
.append("g")
|
||||
.attr("fill", "none")
|
||||
.selectAll("path")
|
||||
.data(sankeyData.links)
|
||||
.join("path")
|
||||
.attr("d", (d) => {
|
||||
const sourceX = d.source.x1;
|
||||
const targetX = d.target.x0;
|
||||
const path = d3.linkHorizontal()({
|
||||
source: [sourceX, d.y0],
|
||||
target: [targetX, d.y1]
|
||||
});
|
||||
return path;
|
||||
})
|
||||
.attr("stroke", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`)
|
||||
.attr("stroke-width", (d) => Math.max(1, d.width))
|
||||
.append("title")
|
||||
.text((d) => `${nodes[d.source.index].name} → ${nodes[d.target.index].name}: ${this.currencySymbolValue}${Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${d.percentage}%)`);
|
||||
|
||||
// Draw nodes
|
||||
const node = svg
|
||||
.append("g")
|
||||
.selectAll("g")
|
||||
.data(sankeyData.nodes)
|
||||
.join("g");
|
||||
|
||||
const cornerRadius = 8;
|
||||
|
||||
node.append("path")
|
||||
.attr("d", (d) => {
|
||||
const x0 = d.x0;
|
||||
const y0 = d.y0;
|
||||
const x1 = d.x1;
|
||||
const y1 = d.y1;
|
||||
const h = y1 - y0;
|
||||
// const w = x1 - x0; // Not directly used in path string, but good for context
|
||||
|
||||
// Dynamic corner radius based on node height, maxed at 8
|
||||
const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2));
|
||||
|
||||
const isSourceNode = d.sourceLinks && d.sourceLinks.length > 0 && (!d.targetLinks || d.targetLinks.length === 0);
|
||||
const isTargetNode = d.targetLinks && d.targetLinks.length > 0 && (!d.sourceLinks || d.sourceLinks.length === 0);
|
||||
|
||||
if (isSourceNode) { // Round left corners, flat right for "Total Income"
|
||||
if (h < effectiveCornerRadius * 2) {
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
}
|
||||
return `M ${x0 + effectiveCornerRadius},${y0}
|
||||
L ${x1},${y0}
|
||||
L ${x1},${y1}
|
||||
L ${x0 + effectiveCornerRadius},${y1}
|
||||
Q ${x0},${y1} ${x0},${y1 - effectiveCornerRadius}
|
||||
L ${x0},${y0 + effectiveCornerRadius}
|
||||
Q ${x0},${y0} ${x0 + effectiveCornerRadius},${y0} Z`;
|
||||
}
|
||||
|
||||
if (isTargetNode) { // Flat left corners, round right for Categories/Surplus
|
||||
if (h < effectiveCornerRadius * 2) {
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
}
|
||||
return `M ${x0},${y0}
|
||||
L ${x1 - effectiveCornerRadius},${y0}
|
||||
Q ${x1},${y0} ${x1},${y0 + effectiveCornerRadius}
|
||||
L ${x1},${y1 - effectiveCornerRadius}
|
||||
Q ${x1},${y1} ${x1 - effectiveCornerRadius},${y1}
|
||||
L ${x0},${y1} Z`;
|
||||
}
|
||||
|
||||
// Fallback for intermediate nodes (e.g., "Cash Flow") - draw as a simple sharp-cornered rectangle
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
})
|
||||
.attr("fill", (d) => d.color || "var(--color-gray-400)")
|
||||
.attr("stroke", (d) => {
|
||||
// If a node has an explicit color assigned (even if it's a gray variable),
|
||||
// it gets no stroke. Only truly un-colored nodes (falling back to default fill)
|
||||
// would get a stroke, but our current data structure assigns colors to all nodes.
|
||||
if (d.color) {
|
||||
return "none";
|
||||
}
|
||||
return "var(--color-gray-500)"; // Fallback, likely unused with current data
|
||||
});
|
||||
|
||||
const stimulusControllerInstance = this;
|
||||
node
|
||||
.append("text")
|
||||
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
|
||||
.attr("y", (d) => (d.y1 + d.y0) / 2)
|
||||
.attr("dy", "-0.2em")
|
||||
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
|
||||
.attr("class", "text-xs font-medium text-primary fill-current")
|
||||
.each(function (d) {
|
||||
const textElement = d3.select(this);
|
||||
textElement.selectAll("tspan").remove();
|
||||
|
||||
// Node Name on the first line
|
||||
textElement.append("tspan")
|
||||
.text(d.name);
|
||||
|
||||
// Financial details on the second line
|
||||
const financialDetailsTspan = textElement.append("tspan")
|
||||
.attr("x", textElement.attr("x"))
|
||||
.attr("dy", "1.2em")
|
||||
.attr("class", "font-mono text-secondary")
|
||||
.style("font-size", "0.65rem"); // Explicitly set smaller font size
|
||||
|
||||
financialDetailsTspan.append("tspan")
|
||||
.text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
|
||||
});
|
||||
}
|
||||
}
|
3
app/javascript/shims/d3-array-default.js
vendored
Normal file
3
app/javascript/shims/d3-array-default.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
import * as d3Array from "d3-array-src";
|
||||
export * from "d3-array-src";
|
||||
export default d3Array;
|
3
app/javascript/shims/d3-shape-default.js
vendored
Normal file
3
app/javascript/shims/d3-shape-default.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
import * as d3Shape from "d3-shape-src";
|
||||
export * from "d3-shape-src";
|
||||
export default d3Shape;
|
|
@ -31,13 +31,21 @@
|
|||
period: @period
|
||||
} %>
|
||||
</section>
|
||||
<section>
|
||||
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
|
||||
</section>
|
||||
|
||||
<%= turbo_frame_tag "cashflow_sankey_section" do %>
|
||||
<section class="bg-container py-4 rounded-xl shadow-border-xs">
|
||||
<%= render partial: "pages/dashboard/cashflow_sankey", locals: {
|
||||
sankey_data: @cashflow_sankey_data,
|
||||
period: @cashflow_period
|
||||
} %>
|
||||
</section>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<section>
|
||||
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<section>
|
||||
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
|
||||
</section>
|
||||
</div>
|
||||
|
|
24
app/views/pages/dashboard/_cashflow_sankey.html.erb
Normal file
24
app/views/pages/dashboard/_cashflow_sankey.html.erb
Normal file
|
@ -0,0 +1,24 @@
|
|||
<%# locals: (sankey_data:, period:) %>
|
||||
<div id="cashflow-sankey-chart">
|
||||
<div class="flex justify-between items-center gap-4 px-4 mb-4">
|
||||
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
Cashflow
|
||||
</h2>
|
||||
|
||||
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "cashflow_sankey_section" } do |form| %>
|
||||
<%= form.select :cashflow_period,
|
||||
Period.as_options,
|
||||
{ selected: period.key },
|
||||
data: { "auto-submit-form-target": "auto" },
|
||||
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-96">
|
||||
<div
|
||||
data-controller="sankey-chart"
|
||||
data-sankey-chart-data-value="<%= sankey_data.to_json %>"
|
||||
data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>"
|
||||
class="w-full h-full"></div>
|
||||
</div>
|
||||
</div>
|
Loading…
Add table
Add a link
Reference in a new issue