mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 16:59:03 +00:00
* fix(charts): auto-fit donut center text to inner ring (#2002) * fix(charts): use Number.parseFloat for biome lint * fix(charts): use rendered donut diameter and destructive token
This commit is contained in:
@@ -3,7 +3,7 @@ import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="donut-chart"
|
||||
export default class extends Controller {
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent"];
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent", "amount"];
|
||||
static values = {
|
||||
segments: { type: Array, default: [] },
|
||||
unusedSegmentId: { type: String, default: "unused" },
|
||||
@@ -21,12 +21,27 @@ export default class extends Controller {
|
||||
#minSegmentAngle = 0.02; // Minimum angle in radians (~1.15 degrees)
|
||||
#padAngle = 0.005; // Spacing between segments (~0.29 degrees)
|
||||
#visiblePaths = null;
|
||||
#resizeObserver = null;
|
||||
#measureCanvas = null;
|
||||
// Largest square inscribed in a circle has side D/√2 ≈ 0.707·D. A single
|
||||
// line of text only needs horizontal room, so 0.78 leaves a touch of
|
||||
// padding without being overly conservative.
|
||||
#innerRingTextWidthRatio = 0.78;
|
||||
// ~text-sm (0.875rem at the default 16px root). Acceptance criterion is
|
||||
// "shrink proportionally, never below text-sm".
|
||||
#minAmountFontSizePx = 14;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
this.#fitAmountTargets();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
this.contentContainerTarget.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
|
||||
if (typeof ResizeObserver !== "undefined" && this.hasChartContainerTarget) {
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#fitAmountTargets());
|
||||
this.#resizeObserver.observe(this.chartContainerTarget);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -34,6 +49,11 @@ export default class extends Controller {
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
this.contentContainerTarget.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
|
||||
if (this.#resizeObserver) {
|
||||
this.#resizeObserver.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
get #data() {
|
||||
@@ -202,6 +222,10 @@ export default class extends Controller {
|
||||
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
template.classList.remove("hidden");
|
||||
|
||||
// The newly-visible amount is now in flow; re-fit in case the container
|
||||
// size has changed since initial draw.
|
||||
this.#fitAmountTargets(template);
|
||||
}
|
||||
|
||||
// Restores original segment colors and hides segment specific content
|
||||
@@ -254,4 +278,72 @@ export default class extends Controller {
|
||||
|
||||
paths.style("opacity", null); // Clear inline opacity style
|
||||
}
|
||||
|
||||
// Shrinks amount text down so it never overflows the inner ring of the
|
||||
// donut. Re-runs on draw, on resize, and when a segment template becomes
|
||||
// visible. Optional `scope` limits the work to a subtree (e.g. the segment
|
||||
// that just appeared).
|
||||
#fitAmountTargets(scope = null) {
|
||||
if (!this.hasChartContainerTarget || !this.hasAmountTarget) return;
|
||||
|
||||
// The donut SVG uses `preserveAspectRatio="xMidYMid meet"`, so the actual
|
||||
// rendered diameter is the *smaller* of the container's width and height.
|
||||
// Using width alone over-estimates available room in non-square cells
|
||||
// (e.g. the budget show page renders the donut inside a grid column that
|
||||
// grows wider than tall on large viewports).
|
||||
const rect = this.chartContainerTarget.getBoundingClientRect();
|
||||
const containerSize = Math.min(rect.width, rect.height);
|
||||
if (containerSize <= 0) return;
|
||||
|
||||
const innerDiameterRatio =
|
||||
(this.#viewBoxSize - 2 * this.segmentHeightValue) / this.#viewBoxSize;
|
||||
const availableWidth = containerSize * innerDiameterRatio * this.#innerRingTextWidthRatio;
|
||||
if (availableWidth <= 0) return;
|
||||
|
||||
const targets = scope
|
||||
? this.amountTargets.filter((el) => scope.contains(el))
|
||||
: this.amountTargets;
|
||||
|
||||
targets.forEach((el) => this.#fitAmountElement(el, availableWidth));
|
||||
}
|
||||
|
||||
#fitAmountElement(element, availableWidth) {
|
||||
// Reset previous inline sizing so we measure at the source size each pass.
|
||||
element.style.fontSize = "";
|
||||
|
||||
const text = element.textContent.trim();
|
||||
if (!text) return;
|
||||
|
||||
const computed = window.getComputedStyle(element);
|
||||
const baseFontSize = Number.parseFloat(computed.fontSize);
|
||||
if (!baseFontSize) return;
|
||||
|
||||
// Canvas-based measurement works for hidden elements (segment_<id>
|
||||
// templates start with `display: none`), where scrollWidth would be 0.
|
||||
const intrinsicWidth = this.#measureTextWidth(text, computed, baseFontSize);
|
||||
if (intrinsicWidth <= 0 || intrinsicWidth <= availableWidth) return;
|
||||
|
||||
const scaled = Math.max(
|
||||
this.#minAmountFontSizePx,
|
||||
Math.floor((availableWidth / intrinsicWidth) * baseFontSize),
|
||||
);
|
||||
|
||||
if (Math.abs(scaled - baseFontSize) >= 1) {
|
||||
element.style.fontSize = `${scaled}px`;
|
||||
}
|
||||
}
|
||||
|
||||
#measureTextWidth(text, computed, fontSize) {
|
||||
if (!this.#measureCanvas) {
|
||||
this.#measureCanvas = document.createElement("canvas");
|
||||
}
|
||||
const ctx = this.#measureCanvas.getContext("2d");
|
||||
if (!ctx) return 0;
|
||||
|
||||
const fontStyle = computed.fontStyle || "normal";
|
||||
const fontWeight = computed.fontWeight || "400";
|
||||
const fontFamily = computed.fontFamily || "sans-serif";
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span><%= t(".spent") %></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-3xl font-medium privacy-sensitive <%= budget.available_to_spend.negative? ? "text-red-500" : "text-primary" %>">
|
||||
<div data-donut-chart-target="amount" class="mb-2 text-3xl font-medium privacy-sensitive whitespace-nowrap <%= budget.available_to_spend.negative? ? "text-destructive" : "text-primary" %>">
|
||||
<%= format_money(budget.actual_spending_money) %>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
href: edit_budget_path(budget)
|
||||
) %>
|
||||
<% else %>
|
||||
<div class="text-subdued text-3xl mb-2 privacy-sensitive">
|
||||
<div data-donut-chart-target="amount" class="text-subdued text-3xl mb-2 privacy-sensitive whitespace-nowrap">
|
||||
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<p class="text-sm text-secondary"><%= bc.category.name %></p>
|
||||
</div>
|
||||
|
||||
<p class="text-3xl font-medium privacy-sensitive <%= bc.available_to_spend.negative? ? "text-red-500" : "text-primary" %>">
|
||||
<p data-donut-chart-target="amount" class="text-3xl font-medium privacy-sensitive whitespace-nowrap <%= bc.available_to_spend.negative? ? "text-destructive" : "text-primary" %>">
|
||||
<%= format_money(bc.actual_spending_money) %>
|
||||
</p>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div id="segment_unused" class="hidden">
|
||||
<p class="text-sm text-secondary text-center mb-2"><%= t(".unused") %></p>
|
||||
|
||||
<p class="text-3xl font-medium text-primary privacy-sensitive">
|
||||
<p data-donut-chart-target="amount" class="text-3xl font-medium text-primary privacy-sensitive whitespace-nowrap">
|
||||
<%= format_money(budget.available_to_spend_money) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<span><%= t("pages.dashboard.outflows_donut.total_outflows") %></span>
|
||||
</div>
|
||||
|
||||
<div class="text-3xl font-medium text-primary privacy-sensitive">
|
||||
<div data-donut-chart-target="amount" class="text-3xl font-medium text-primary privacy-sensitive whitespace-nowrap">
|
||||
<%= format_money Money.new(outflows_data[:total], outflows_data[:currency]) %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<p class="text-sm text-secondary"><%= category[:name] %></p>
|
||||
|
||||
<p class="text-3xl font-medium text-primary privacy-sensitive">
|
||||
<p data-donut-chart-target="amount" class="text-3xl font-medium text-primary privacy-sensitive whitespace-nowrap">
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>
|
||||
</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user