import { Controller } from "@hotwired/stimulus" import tailwindColors from "@maybe/tailwindcolors" import * as d3 from "d3" const CHARTABLE_TYPES = [ "scalar", "currency" ] export default class extends Controller { static values = { series: Object, chartedType: String } #_dataPoints = [] #_d3Svg = null #_d3InitialContainerWidth = 0 #_d3InitialContainerHeight = 0 connect() { this.#install() document.addEventListener("turbo:load", this.#reinstall) } disconnect() { document.removeEventListener("turbo:load", this.#reinstall) } #reinstall = () => { this.#teardown() this.#install() } #teardown() { this.#_d3Svg = null this.#_dataPoints = [] this.#d3Container.selectAll("*").remove() } #install() { this.#normalizeDataPoints() this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight this.#draw() } #normalizeDataPoints() { this.#dataPoints = (this.seriesValue.values || []).map((d) => ({ ...d, date: new Date(d.date), value: d.value.amount ? +d.value.amount : +d.value, currency: d.value.currency || "USD", })) } #draw() { if (this.#dataPoints.length < 2) { this.#drawEmpty() } else if (this.#chartedType === "currency") { this.#drawCurrency() } else { this.#drawLine() } } #drawEmpty() { this.#d3Svg.selectAll(".tick").remove() this.#d3Svg.selectAll(".domain").remove() this.#d3Svg .append("line") .attr("x1", this.#d3InitialContainerWidth / 2) .attr("y1", 0) .attr("x2", this.#d3InitialContainerWidth / 2) .attr("y2", this.d3InitialContainerHeight) .attr("stroke", tailwindColors.gray[300]) .attr("stroke-dasharray", "4, 4") this.#d3Svg .append("circle") .attr("cx", this.#d3InitialContainerWidth / 2) .attr("cy", this.d3InitialContainerHeight / 2) .attr("r", 4) .style("fill", tailwindColors.gray[400]) } #drawCurrency() { const g = this.#d3Svg .append("g") .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`) // X-Axis labels g.append("g") .attr("transform", `translate(0,${this.#d3ContainerHeight})`) .call( d3 .axisBottom(this.#d3XScale) .tickValues([ this.#dataPoints[0].date, this.#dataPoints[this.#dataPoints.length - 1].date ]) .tickSize(0) .tickFormat(d3.timeFormat("%b %Y")) ) .select(".domain") .remove() g.selectAll(".tick text") .style("fill", tailwindColors.gray[500]) .style("font-size", "12px") .style("font-weight", "500") .attr("text-anchor", "middle") .attr("dx", (_d, i) => { // We know we only have 2 values return i === 0 ? "5em" : "-5em" }) .attr("dy", "0em") g.append("path") .datum(this.#dataPoints) .attr("fill", "none") .attr("stroke", tailwindColors.green[500]) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .attr("stroke-width", 1.5) .attr("class", "line-chart-path") .attr("d", this.#d3Line) const tooltip = d3 .select("#lineChart") .append("div") .style("position", "absolute") .style("padding", "8px") .style("font", "14px Inter, sans-serif") .style("background", tailwindColors.white) .style("border", `1px solid ${tailwindColors["alpha-black"][100]}`) .style("border-radius", "10px") .style("pointer-events", "none") .style("opacity", 0) // Starts as hidden // Helper to find the closest data point to the mouse const bisectDate = d3.bisector(function (d) { return d.date }).left // Create an invisible rectangle that captures mouse events (regular SVG elements don't capture mouse events by default) g.append("rect") .attr("width", this.#d3ContainerWidth) .attr("height", this.#d3ContainerHeight) .attr("fill", "none") .attr("pointer-events", "all") // When user hovers over the chart, show the tooltip and a circle at the closest data point .on("mousemove", (event) => { tooltip.style("opacity", 1) const tooltipWidth = 250 // Estimate or dynamically calculate the tooltip width const pageWidth = document.body.clientWidth const tooltipX = event.pageX + 10 const overflowX = tooltipX + tooltipWidth - pageWidth const [xPos] = d3.pointer(event) const x0 = bisectDate(this.#dataPoints, this.#d3XScale.invert(xPos), 1) const d0 = this.#dataPoints[x0 - 1] const d1 = this.#dataPoints[x0] const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0 // Adjust tooltip position based on overflow const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX g.selectAll(".data-point-circle").remove() // Remove existing circles to ensure only one is shown at a time g.append("circle") .attr("class", "data-point-circle") .attr("cx", this.#d3XScale(d.date)) .attr("cy", this.#d3YScale(d.value)) .attr("r", 8) .attr("fill", tailwindColors.green[500]) .attr("fill-opacity", "0.1") .attr("pointer-events", "none") g.append("circle") .attr("class", "data-point-circle") .attr("cx", this.#d3XScale(d.date)) .attr("cy", this.#d3YScale(d.value)) .attr("r", 3) .attr("fill", tailwindColors.green[500]) .attr("pointer-events", "none") tooltip .html( `