import { Controller } from "@hotwired/stimulus"; import tailwindColors from "@maybe/tailwindcolors"; import * as d3 from "d3"; // Connects to data-controller="line-chart" export default class extends Controller { static values = { series: Object }; connect() { this.renderChart(this.seriesValue); document.addEventListener("turbo:load", this.renderChart); } disconnect() { document.removeEventListener("turbo:load", this.renderChart); } renderChart = () => { this.drawChart(this.seriesValue); }; trendStyles(trendDirection) { return { up: { icon: "↑", color: tailwindColors.success, }, down: { icon: "↓", color: tailwindColors.error, }, flat: { icon: "→", color: tailwindColors.gray[500], }, }[trendDirection]; } drawChart(series) { const data = series.data.map((b) => ({ date: new Date(b.date + "T00:00:00"), value: +b.amount, styles: this.trendStyles(b.trend.direction), trend: b.trend, formatted: { value: Intl.NumberFormat("en-US", { style: "currency", currency: b.currency.iso_code || "USD", }).format(b.amount), change: Intl.NumberFormat("en-US", { style: "currency", currency: b.currency.iso_code || "USD", signDisplay: "always", }).format(b.trend.amount), }, })); const chartContainer = d3.select(this.element); // Clear any existing chart chartContainer.selectAll("svg").remove(); const initialDimensions = { width: chartContainer.node().clientWidth, height: chartContainer.node().clientHeight, }; const svg = chartContainer .append("svg") .attr("width", initialDimensions.width) .attr("height", initialDimensions.height) .attr("viewBox", [ 0, 0, initialDimensions.width, initialDimensions.height, ]) .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); const margin = { top: 20, right: 1, bottom: 30, left: 1 }, width = +svg.attr("width") - margin.left - margin.right, height = +svg.attr("height") - margin.top - margin.bottom, g = svg .append("g") .attr("transform", `translate(${margin.left},${margin.top})`); // X-Axis const x = d3 .scaleTime() .rangeRound([0, width]) .domain(d3.extent(data, (d) => d.date)); const PADDING = 0.15; // 15% padding on top and bottom of data const dataMin = d3.min(data, (d) => d.value); const dataMax = d3.max(data, (d) => d.value); const padding = (dataMax - dataMin) * PADDING; // Y-Axis const y = d3 .scaleLinear() .rangeRound([height, 0]) .domain([dataMin - padding, dataMax + padding]); // X-Axis labels g.append("g") .attr("transform", `translate(0,${height})`) .call( d3 .axisBottom(x) .tickValues([data[0].date, data[data.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"); // Line const line = d3 .line() .x((d) => x(d.date)) .y((d) => y(d.value)); g.append("path") .datum(data) .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", line); 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", width) .attr("height", height) .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(data, x.invert(xPos), 1); const d0 = data[x0 - 1]; const d1 = data[x0]; const d = xPos - x(d0.date) > x(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", x(d.date)) .attr("cy", y(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", x(d.date)) .attr("cy", y(d.value)) .attr("r", 3) .attr("fill", tailwindColors.green[500]) .attr("pointer-events", "none"); tooltip .html( `