1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-23 15:19:38 +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:
Josh Brown 2024-04-23 17:05:18 +01:00 committed by GitHub
parent 8a29725562
commit 1f6e83ee91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 16 deletions

View file

@ -7,6 +7,10 @@ module AccountsHelper
class_mapping(accountable_type)[:text] class_mapping(accountable_type)[:text]
end end
def accountable_fill_class(accountable_type)
class_mapping(accountable_type)[:fill]
end
def accountable_bg_class(accountable_type) def accountable_bg_class(accountable_type)
class_mapping(accountable_type)[:bg] class_mapping(accountable_type)[:bg]
end end
@ -19,14 +23,14 @@ module AccountsHelper
def class_mapping(accountable_type) def class_mapping(accountable_type)
{ {
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-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" }, "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" }, "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" }, "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" }, "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" }, "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" }, "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" } "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" }) }.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500" })
end end
end end

View 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

View 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;
}
}

View file

@ -326,6 +326,7 @@ export default class extends Controller {
this.#d3Tooltip this.#d3Tooltip
.html(this.#tooltipTemplate(d)) .html(this.#tooltipTemplate(d))
.style("opacity", 1) .style("opacity", 1)
.style("z-index", 999)
.style("left", adjustedX + "px") .style("left", adjustedX + "px")
.style("top", event.pageY - 10 + "px") .style("top", event.pageY - 10 + "px")
}) })

View file

@ -11,7 +11,7 @@
<% end %> <% end %>
</header> </header>
<section class="flex gap-4"> <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 class="flex justify-between p-4">
<div> <div>
<%= render partial: "shared/value_heading", locals: { <%= render partial: "shared/value_heading", locals: {
@ -28,7 +28,7 @@
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @net_worth_series } %> <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @net_worth_series } %>
</div> </div>
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl w-1/4"> <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> </div>
</section> </section>
<section class="grid grid-cols-2 gap-4"> <section class="grid grid-cols-2 gap-4">

View file

@ -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 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"> <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> <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> <div>
<div data-tabs-target="tab" id="asset-tab" class="space-y-6"> <div data-tabs-target="tab" id="asset-tab" class="space-y-6">
<div class="text-gray-500 flex items-center justify-center py-12"> <div class="text-gray-500 flex items-center justify-center py-6">
<p>Coming soon...</p> <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> </div>
<div data-tabs-target="tab" id="liability-tab" class="space-y-6 hidden"> <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"> <div class="text-gray-500 flex items-center justify-center py-6">
<p>Coming soon...</p> <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> </div>
</div> </div>

View file

@ -2,7 +2,7 @@
<% if series %> <% if series %>
<div <div
id="netWorthChart" id="netWorthChart"
class="w-full h-full" class="w-full flex-1 min-h-52"
data-controller="time-series-chart" data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div> data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %> <% else %>