mirror of
https://github.com/we-promise/sure.git
synced 2026-05-08 13:14:58 +00:00
* Fix budget donut chart hiding center content on segment hover * Preserve segment hover in center unless leaving the unused arc The unused arc has no interactive link in its center template, so moving from it into the center content should restore default content(including the Edit Budget link) immediately. All other segments have a DS::Link in their center template that must remain clickable after the cursor moves from the arc into the center. Pass the D3 datum into the mouseleave handler to read d.data.id, then clear hover when either (a) the departing segment is the unused arc, or (b) the cursor is not heading into contentContainerTarget.
258 lines
8.3 KiB
JavaScript
258 lines
8.3 KiB
JavaScript
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 },
|
|
extendedHover: { type: Boolean, default: false },
|
|
hoverExtension: { type: Number, default: 3 },
|
|
enableClick: { type: Boolean, default: false },
|
|
startDate: String,
|
|
endDate: String,
|
|
};
|
|
|
|
#viewBoxSize = 100;
|
|
#minSegmentAngle = 0.02; // Minimum angle in radians (~1.15 degrees)
|
|
#padAngle = 0.005; // Spacing between segments (~0.29 degrees)
|
|
#visiblePaths = null;
|
|
|
|
connect() {
|
|
this.#draw();
|
|
document.addEventListener("turbo:load", this.#redraw);
|
|
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
|
this.contentContainerTarget.addEventListener("mouseleave", this.#clearSegmentHover);
|
|
}
|
|
|
|
disconnect() {
|
|
this.#teardown();
|
|
document.removeEventListener("turbo:load", this.#redraw);
|
|
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
|
this.contentContainerTarget.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 / (2 * Math.PI)),
|
|
),
|
|
}))
|
|
.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() {
|
|
if (this.hasChartContainerTarget) {
|
|
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
|
}
|
|
this.#visiblePaths = null;
|
|
}
|
|
|
|
#draw() {
|
|
if (!this.hasChartContainerTarget) return;
|
|
|
|
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.#padAngle);
|
|
|
|
const g = svg
|
|
.append("g")
|
|
.attr(
|
|
"transform",
|
|
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
|
|
);
|
|
|
|
const segmentGroups = g
|
|
.selectAll("arc")
|
|
.data(pie(this.#data))
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", "arc pointer-events-auto");
|
|
|
|
// Add invisible hover paths with extended area if enabled
|
|
if (this.extendedHoverValue) {
|
|
const hoverArc = d3
|
|
.arc()
|
|
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue - this.hoverExtensionValue)
|
|
.outerRadius(this.#viewBoxSize / 2 + this.hoverExtensionValue)
|
|
.padAngle(this.#padAngle);
|
|
|
|
segmentGroups
|
|
.append("path")
|
|
.attr("class", "hover-path")
|
|
.attr("d", hoverArc)
|
|
.attr("fill", "transparent")
|
|
.attr("data-segment-id", (d) => d.data.id)
|
|
.style("pointer-events", "all");
|
|
}
|
|
|
|
// Add visible paths
|
|
const segmentArcs = segmentGroups
|
|
.append("path")
|
|
.attr("class", "visible-path")
|
|
.attr("data-segment-id", (d) => d.data.id)
|
|
.attr("data-original-color", this.#transformRingColor)
|
|
.attr("fill", this.#transformRingColor)
|
|
.attr("d", mainArc);
|
|
|
|
// Disable pointer events on visible paths if extended hover is enabled
|
|
if (this.extendedHoverValue) {
|
|
segmentArcs.style("pointer-events", "none");
|
|
}
|
|
|
|
// Cache the visible paths selection for performance
|
|
this.#visiblePaths = d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
|
|
|
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
|
|
let hoverTimeout = null;
|
|
|
|
segmentGroups
|
|
.on("mouseover", (event) => {
|
|
hoverTimeout = setTimeout(() => {
|
|
this.#clearSegmentHover();
|
|
this.#handleSegmentHover(event);
|
|
}, 10);
|
|
})
|
|
.on("mouseleave", (event, d) => {
|
|
clearTimeout(hoverTimeout);
|
|
const leavingUnused = d.data.id === this.unusedSegmentIdValue;
|
|
if (leavingUnused || !this.contentContainerTarget.contains(event.relatedTarget)) {
|
|
this.#clearSegmentHover();
|
|
}
|
|
})
|
|
.on("click", (event, d) => {
|
|
if (this.enableClickValue) {
|
|
this.#handleClick(d.data);
|
|
}
|
|
});
|
|
}
|
|
|
|
#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;
|
|
|
|
// Use cached selection if available for better performance
|
|
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
|
|
|
paths.attr("fill", function () {
|
|
if (this.dataset.segmentId === segmentId) {
|
|
if (this.dataset.segmentId === unusedSegmentId) {
|
|
return "var(--budget-unused-fill)";
|
|
}
|
|
|
|
return this.dataset.originalColor;
|
|
}
|
|
|
|
return "var(--budget-unallocated-fill)";
|
|
});
|
|
|
|
this.defaultContentTarget.classList.add("hidden");
|
|
template.classList.remove("hidden");
|
|
}
|
|
|
|
// Restores original segment colors and hides segment specific content
|
|
#clearSegmentHover = () => {
|
|
this.defaultContentTarget.classList.remove("hidden");
|
|
|
|
// Use cached selection if available for better performance
|
|
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
|
|
|
paths
|
|
.attr("fill", function () {
|
|
return this.dataset.originalColor;
|
|
})
|
|
.style("opacity", null); // Clear inline opacity style
|
|
|
|
for (const child of this.contentContainerTarget.children) {
|
|
if (child !== this.defaultContentTarget) {
|
|
child.classList.add("hidden");
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handles click on segment (optional, controlled by enableClick value)
|
|
#handleClick(segment) {
|
|
if (!segment.name || !this.startDateValue || !this.endDateValue) return;
|
|
|
|
const segmentName = encodeURIComponent(segment.name);
|
|
const startDate = this.startDateValue;
|
|
const endDate = this.endDateValue;
|
|
|
|
const url = `/transactions?q[categories][]=${segmentName}&q[start_date]=${startDate}&q[end_date]=${endDate}`;
|
|
window.location.href = url;
|
|
}
|
|
|
|
// Public methods for external highlighting (e.g., from category list hover)
|
|
highlightSegment(event) {
|
|
const segmentId = event.currentTarget.dataset.categoryId;
|
|
|
|
// Use cached selection if available for better performance
|
|
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
|
|
|
paths.style("opacity", function() {
|
|
return this.dataset.segmentId === segmentId ? 1 : 0.3;
|
|
});
|
|
}
|
|
|
|
unhighlightSegment() {
|
|
// Use cached selection if available for better performance
|
|
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
|
|
|
paths.style("opacity", null); // Clear inline opacity style
|
|
}
|
|
}
|