mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 22:59:39 +02:00
Add pie chart for asset/debt allocation in dashboard view (#666)
* Add pie chart for asset/debt allocation in dashboard view * Fix lint issue * Fix z-index issue with tooltip under pie chart * Fix spacing of dashboard charts
This commit is contained in:
parent
8a29725562
commit
1f6e83ee91
7 changed files with 225 additions and 16 deletions
|
@ -7,6 +7,10 @@ module AccountsHelper
|
|||
class_mapping(accountable_type)[:text]
|
||||
end
|
||||
|
||||
def accountable_fill_class(accountable_type)
|
||||
class_mapping(accountable_type)[:fill]
|
||||
end
|
||||
|
||||
def accountable_bg_class(accountable_type)
|
||||
class_mapping(accountable_type)[:bg]
|
||||
end
|
||||
|
@ -19,14 +23,14 @@ module AccountsHelper
|
|||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10" },
|
||||
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10" },
|
||||
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" },
|
||||
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10" },
|
||||
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10" },
|
||||
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10" },
|
||||
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10" },
|
||||
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" })
|
||||
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500" },
|
||||
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500" },
|
||||
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500" },
|
||||
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500" },
|
||||
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600" },
|
||||
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500" },
|
||||
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500" },
|
||||
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500" })
|
||||
end
|
||||
end
|
||||
|
|
17
app/helpers/value_groups_helper.rb
Normal file
17
app/helpers/value_groups_helper.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
module ValueGroupsHelper
|
||||
def value_group_pie_data(value_group)
|
||||
value_group.children
|
||||
.map do |child|
|
||||
{
|
||||
label: to_accountable_title(Accountable.from_type(child.name)),
|
||||
percent_of_total: child.percent_of_total.round(1).to_f,
|
||||
value: child.sum.amount.to_f,
|
||||
currency: child.sum.currency.iso_code,
|
||||
bg_color: accountable_bg_class(child.name),
|
||||
fill_color: accountable_fill_class(child.name)
|
||||
}
|
||||
end
|
||||
.filter { |child| child[:value] > 0 }
|
||||
.to_json
|
||||
end
|
||||
end
|
176
app/javascript/controllers/pie_chart_controller.js
Normal file
176
app/javascript/controllers/pie_chart_controller.js
Normal file
|
@ -0,0 +1,176 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="pie-chart"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Array,
|
||||
label: String,
|
||||
};
|
||||
|
||||
#d3SvgMemo = null;
|
||||
#d3GroupMemo = null;
|
||||
#d3ContentMemo = null;
|
||||
#d3ViewboxWidth = 200;
|
||||
#d3ViewboxHeight = 200;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#teardown();
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
}
|
||||
|
||||
#redraw = () => {
|
||||
this.#teardown();
|
||||
this.#draw();
|
||||
};
|
||||
|
||||
#teardown() {
|
||||
this.#d3SvgMemo = null;
|
||||
this.#d3GroupMemo = null;
|
||||
this.#d3ContentMemo = null;
|
||||
this.#d3Container.selectAll("*").remove();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
this.#d3Container.attr("class", "relative");
|
||||
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
.value((d) => d.percent_of_total)
|
||||
.padAngle(0.06);
|
||||
|
||||
const arc = d3
|
||||
.arc()
|
||||
.innerRadius(this.#radius - 8)
|
||||
.outerRadius(this.#radius)
|
||||
.cornerRadius(2);
|
||||
|
||||
const arcs = this.#d3Group
|
||||
.selectAll("arc")
|
||||
.data(pie(this.dataValue))
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "arc");
|
||||
|
||||
const paths = arcs
|
||||
.append("path")
|
||||
.attr("class", (d) => d.data.fill_color)
|
||||
.attr("d", arc);
|
||||
|
||||
paths
|
||||
.on("mouseover", (event) => {
|
||||
this.#d3Svg.selectAll(".arc path").attr("class", "fill-gray-200");
|
||||
d3.select(event.target).attr("class", (d) => d.data.fill_color);
|
||||
this.#d3ContentMemo.html(
|
||||
this.#contentDetailTemplate(d3.select(event.target).datum().data),
|
||||
);
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
this.#d3Svg
|
||||
.selectAll(".arc path")
|
||||
.attr("class", (d) => d.data.fill_color);
|
||||
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
});
|
||||
}
|
||||
|
||||
#contentSummaryTemplate(data) {
|
||||
const total = data.reduce((acc, cur) => acc + cur.value, 0);
|
||||
const currency = data[0].currency;
|
||||
|
||||
return `${this.#currencyValue({
|
||||
value: total,
|
||||
currency,
|
||||
})} <span class="text-xs">${this.labelValue}</span>`;
|
||||
}
|
||||
|
||||
#contentDetailTemplate(datum) {
|
||||
return `
|
||||
<span>${this.#currencyValue(datum)}</span>
|
||||
<div class="flex flex-row text-xs gap-2 items-center">
|
||||
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
|
||||
<span>${datum.label}</span>
|
||||
<span>${datum.percent_of_total}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#currencyValue(datum) {
|
||||
const formattedValue = Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(datum.value);
|
||||
|
||||
const firstDigitIndex = formattedValue.search(/\d/);
|
||||
const currencyPrefix = formattedValue.substring(0, firstDigitIndex);
|
||||
const mainPart = formattedValue.substring(firstDigitIndex);
|
||||
const [integerPart, fractionalPart] = mainPart.split(".");
|
||||
|
||||
return `<p class="text-gray-500 -space-x-0.5">${currencyPrefix}<span class="text-xl text-gray-900 font-medium">${integerPart}</span>.${fractionalPart}</p>`;
|
||||
}
|
||||
|
||||
get #radius() {
|
||||
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
|
||||
}
|
||||
|
||||
get #d3Container() {
|
||||
return d3.select(this.element);
|
||||
}
|
||||
|
||||
get #d3Svg() {
|
||||
if (this.#d3SvgMemo) {
|
||||
return this.#d3SvgMemo;
|
||||
} else {
|
||||
return (this.#d3SvgMemo = this.#createMainSvg());
|
||||
}
|
||||
}
|
||||
|
||||
get #d3Group() {
|
||||
if (this.#d3GroupMemo) {
|
||||
return this.#d3GroupMemo;
|
||||
} else {
|
||||
return (this.#d3GroupMemo = this.#createMainGroup());
|
||||
}
|
||||
}
|
||||
|
||||
get #d3Content() {
|
||||
if (this.#d3ContentMemo) {
|
||||
return this.#d3ContentMemo;
|
||||
} else {
|
||||
return (this.#d3ContentMemo = this.#createContent());
|
||||
}
|
||||
}
|
||||
|
||||
#createMainSvg() {
|
||||
return this.#d3Container
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("class", "relative aspect-1")
|
||||
.attr("viewBox", [0, 0, this.#d3ViewboxWidth, this.#d3ViewboxHeight]);
|
||||
}
|
||||
|
||||
#createMainGroup() {
|
||||
return this.#d3Svg
|
||||
.append("g")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${this.#d3ViewboxWidth / 2},${this.#d3ViewboxHeight / 2})`,
|
||||
);
|
||||
}
|
||||
|
||||
#createContent() {
|
||||
this.#d3ContentMemo = this.#d3Container
|
||||
.append("div")
|
||||
.attr(
|
||||
"class",
|
||||
"absolute inset-0 w-full text-center flex flex-col items-center justify-center",
|
||||
);
|
||||
return this.#d3ContentMemo;
|
||||
}
|
||||
}
|
|
@ -326,6 +326,7 @@ export default class extends Controller {
|
|||
this.#d3Tooltip
|
||||
.html(this.#tooltipTemplate(d))
|
||||
.style("opacity", 1)
|
||||
.style("z-index", 999)
|
||||
.style("left", adjustedX + "px")
|
||||
.style("top", event.pageY - 10 + "px")
|
||||
})
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<% end %>
|
||||
</header>
|
||||
<section class="flex gap-4">
|
||||
<div class="bg-white border border-alpha-black-25 shadow-xs rounded-xl w-3/4 min-h-48">
|
||||
<div class="bg-white border border-alpha-black-25 shadow-xs rounded-xl w-3/4 min-h-48 flex flex-col">
|
||||
<div class="flex justify-between p-4">
|
||||
<div>
|
||||
<%= render partial: "shared/value_heading", locals: {
|
||||
|
@ -28,7 +28,7 @@
|
|||
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @net_worth_series } %>
|
||||
</div>
|
||||
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl w-1/4">
|
||||
<%= render partial: "pages/dashboard/allocation_chart" %>
|
||||
<%= render partial: "pages/dashboard/allocation_chart", locals: { account_groups: @account_groups } %>
|
||||
</div>
|
||||
</section>
|
||||
<section class="grid grid-cols-2 gap-4">
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
<%# locals: (account_groups:) -%>
|
||||
<div data-controller="tabs" data-tabs-active-class="bg-white border-alpha-black-25 shadow-xs text-gray-900" data-tabs-default-tab-value="asset-tab">
|
||||
<div class="bg-gray-25 rounded-lg p-1 flex gap-1 text-sm text-gray-500 font-medium">
|
||||
<button data-id="asset-tab" class="w-1/2 px-2 py-1 rounded-md border border-transparent" data-tabs-target="btn" data-action="tabs#select"><%= t(".assets") %></button>
|
||||
|
@ -5,13 +6,23 @@
|
|||
</div>
|
||||
<div>
|
||||
<div data-tabs-target="tab" id="asset-tab" class="space-y-6">
|
||||
<div class="text-gray-500 flex items-center justify-center py-12">
|
||||
<p>Coming soon...</p>
|
||||
<div class="text-gray-500 flex items-center justify-center py-6">
|
||||
<div
|
||||
data-controller="pie-chart"
|
||||
class="w-full aspect-1"
|
||||
data-pie-chart-label-value="Total Assets"
|
||||
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:assets]) %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-tabs-target="tab" id="liability-tab" class="space-y-6 hidden">
|
||||
<div class="text-gray-500 flex items-center justify-center py-12">
|
||||
<p>Coming soon...</p>
|
||||
<div class="text-gray-500 flex items-center justify-center py-6">
|
||||
<div
|
||||
data-controller="pie-chart"
|
||||
class="w-full aspect-1"
|
||||
data-pie-chart-label-value="Total Debts"
|
||||
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:liabilities]) %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<% if series %>
|
||||
<div
|
||||
id="netWorthChart"
|
||||
class="w-full h-full"
|
||||
class="w-full flex-1 min-h-52"
|
||||
data-controller="time-series-chart"
|
||||
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||
<% else %>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue