2025-01-16 14:36:37 -05:00
|
|
|
import { Controller } from "@hotwired/stimulus";
|
|
|
|
import * as d3 from "d3";
|
|
|
|
|
|
|
|
// Connects to data-controller="donut-chart"
|
|
|
|
export default class extends Controller {
|
|
|
|
static targets = ["chartContainer", "contentContainer", "defaultContent"];
|
|
|
|
static values = {
|
|
|
|
segments: { type: Array, default: [] },
|
|
|
|
unusedSegmentId: { type: String, default: "unused" },
|
|
|
|
overageSegmentId: { type: String, default: "overage" },
|
|
|
|
segmentHeight: { type: Number, default: 3 },
|
|
|
|
segmentOpacity: { type: Number, default: 1 },
|
|
|
|
};
|
|
|
|
|
|
|
|
#viewBoxSize = 100;
|
|
|
|
#minSegmentAngle = this.segmentHeightValue * 0.01;
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
this.#draw();
|
|
|
|
document.addEventListener("turbo:load", this.#redraw);
|
|
|
|
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
|
|
|
}
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
this.#teardown();
|
|
|
|
document.removeEventListener("turbo:load", this.#redraw);
|
|
|
|
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
|
|
|
}
|
|
|
|
|
|
|
|
get #data() {
|
|
|
|
const totalPieValue = this.segmentsValue.reduce(
|
|
|
|
(acc, s) => acc + Number(s.amount),
|
|
|
|
0,
|
|
|
|
);
|
|
|
|
|
|
|
|
// Overage is always first segment, unused is always last segment
|
|
|
|
return this.segmentsValue
|
|
|
|
.filter((s) => s.amount > 0)
|
|
|
|
.map((s) => ({
|
|
|
|
...s,
|
|
|
|
amount: Math.max(
|
|
|
|
Number(s.amount),
|
|
|
|
totalPieValue * this.#minSegmentAngle,
|
|
|
|
),
|
|
|
|
}))
|
|
|
|
.sort((a, b) => {
|
|
|
|
if (a.id === this.overageSegmentIdValue) return -1;
|
|
|
|
if (b.id === this.overageSegmentIdValue) return 1;
|
|
|
|
if (a.id === this.unusedSegmentIdValue) return 1;
|
|
|
|
if (b.id === this.unusedSegmentIdValue) return -1;
|
|
|
|
return b.amount - a.amount;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
#redraw = () => {
|
|
|
|
this.#teardown();
|
|
|
|
this.#draw();
|
|
|
|
};
|
|
|
|
|
|
|
|
#teardown() {
|
|
|
|
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
|
|
|
}
|
|
|
|
|
|
|
|
#draw() {
|
|
|
|
const svg = d3
|
|
|
|
.select(this.chartContainerTarget)
|
|
|
|
.append("svg")
|
|
|
|
.attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio
|
|
|
|
.attr("preserveAspectRatio", "xMidYMid meet")
|
|
|
|
.attr("class", "w-full h-full");
|
|
|
|
|
|
|
|
const pie = d3
|
|
|
|
.pie()
|
|
|
|
.sortValues(null) // Preserve order of segments
|
|
|
|
.value((d) => d.amount);
|
|
|
|
|
|
|
|
const mainArc = d3
|
|
|
|
.arc()
|
|
|
|
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)
|
|
|
|
.outerRadius(this.#viewBoxSize / 2)
|
|
|
|
.cornerRadius(this.segmentHeightValue)
|
|
|
|
.padAngle(this.#minSegmentAngle);
|
|
|
|
|
|
|
|
const segmentArcs = svg
|
|
|
|
.append("g")
|
|
|
|
.attr(
|
|
|
|
"transform",
|
|
|
|
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
|
|
|
|
)
|
|
|
|
.selectAll("arc")
|
|
|
|
.data(pie(this.#data))
|
|
|
|
.enter()
|
|
|
|
.append("g")
|
|
|
|
.attr("class", "arc pointer-events-auto")
|
|
|
|
.append("path")
|
|
|
|
.attr("data-segment-id", (d) => d.data.id)
|
|
|
|
.attr("data-original-color", this.#transformRingColor)
|
|
|
|
.attr("fill", this.#transformRingColor)
|
|
|
|
.attr("d", mainArc);
|
|
|
|
|
|
|
|
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
|
|
|
|
let hoverTimeout = null;
|
|
|
|
|
|
|
|
segmentArcs
|
|
|
|
.on("mouseover", (event) => {
|
|
|
|
hoverTimeout = setTimeout(() => {
|
|
|
|
this.#clearSegmentHover();
|
|
|
|
this.#handleSegmentHover(event);
|
|
|
|
}, 150);
|
|
|
|
})
|
|
|
|
.on("mouseleave", () => {
|
|
|
|
clearTimeout(hoverTimeout);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
#transformRingColor = ({ data: { id, color } }) => {
|
|
|
|
if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) {
|
|
|
|
return color;
|
|
|
|
}
|
|
|
|
|
|
|
|
const reducedOpacityColor = d3.color(color);
|
|
|
|
reducedOpacityColor.opacity = this.segmentOpacityValue;
|
|
|
|
return reducedOpacityColor;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Highlights segment and shows segment specific content (all other segments are grayed out)
|
|
|
|
#handleSegmentHover(event) {
|
|
|
|
const segmentId = event.target.dataset.segmentId;
|
|
|
|
const template = this.element.querySelector(`#segment_${segmentId}`);
|
|
|
|
const unusedSegmentId = this.unusedSegmentIdValue;
|
|
|
|
|
|
|
|
if (!template) return;
|
|
|
|
|
|
|
|
d3.select(this.chartContainerTarget)
|
|
|
|
.selectAll("path")
|
|
|
|
.attr("fill", function () {
|
|
|
|
if (this.dataset.segmentId === segmentId) {
|
|
|
|
if (this.dataset.segmentId === unusedSegmentId) {
|
2025-05-07 09:26:06 -04:00
|
|
|
return "var(--budget-unused-fill)";
|
2025-01-16 14:36:37 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
return this.dataset.originalColor;
|
|
|
|
}
|
|
|
|
|
2025-05-07 09:26:06 -04:00
|
|
|
return "var(--budget-unallocated-fill)";
|
2025-01-16 14:36:37 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
this.defaultContentTarget.classList.add("hidden");
|
|
|
|
template.classList.remove("hidden");
|
|
|
|
}
|
|
|
|
|
|
|
|
// Restores original segment colors and hides segment specific content
|
|
|
|
#clearSegmentHover = () => {
|
|
|
|
this.defaultContentTarget.classList.remove("hidden");
|
|
|
|
|
|
|
|
d3.select(this.chartContainerTarget)
|
|
|
|
.selectAll("path")
|
|
|
|
.attr("fill", function () {
|
|
|
|
return this.dataset.originalColor;
|
|
|
|
});
|
|
|
|
|
|
|
|
for (const child of this.contentContainerTarget.children) {
|
|
|
|
if (child !== this.defaultContentTarget) {
|
|
|
|
child.classList.add("hidden");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|