diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index f4686e2d..0e8b2643 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -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 diff --git a/app/helpers/value_groups_helper.rb b/app/helpers/value_groups_helper.rb new file mode 100644 index 00000000..c8e96d07 --- /dev/null +++ b/app/helpers/value_groups_helper.rb @@ -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 diff --git a/app/javascript/controllers/pie_chart_controller.js b/app/javascript/controllers/pie_chart_controller.js new file mode 100644 index 00000000..f68fe5be --- /dev/null +++ b/app/javascript/controllers/pie_chart_controller.js @@ -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, + })} ${this.labelValue}`; + } + + #contentDetailTemplate(datum) { + return ` + ${this.#currencyValue(datum)} +
${currencyPrefix}${integerPart}.${fractionalPart}
`; + } + + 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; + } +} diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index a429ae99..1c518306 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -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") }) diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index afe7f277..8ea2a662 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -11,7 +11,7 @@ <% end %>Coming soon...
+