diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 6f002d45..8db9b186 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -2,11 +2,15 @@ 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 } + static values = { series: Object, chartedType: String } #_dataPoints = [] #_d3Svg = null + #_d3InitialContainerWidth = 0 + #_d3InitialContainerHeight = 0 connect() { this.#install() @@ -30,19 +34,25 @@ export default class extends Controller { #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() } @@ -54,21 +64,176 @@ export default class extends Controller { this.#d3Svg .append("line") - .attr("x1", this.#d3Svg.node().clientWidth / 2) + .attr("x1", this.#d3InitialContainerWidth / 2) .attr("y1", 0) - .attr("x2", this.#d3Svg.node().clientWidth / 2) - .attr("y2", this.#d3Svg.node().clientHeight) + .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.#d3Svg.node().clientWidth / 2) - .attr("cy", this.#d3Svg.node().clientHeight / 2) + .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( + `
+ ${d3.timeFormat("%b %d, %Y")(d.date)} +
+
+ + + + ${this.#currencyValue(d)} ${this.#currencyChange(d)} (${d.trend.percent}%) +
` + ) + .style("left", adjustedX + "px") + .style("top", event.pageY - 10 + "px") + + g.selectAll(".guideline").remove() // Remove existing line to ensure only one is shown at a time + g.append("line") + .attr("class", "guideline") + .attr("x1", this.#d3XScale(d.date)) + .attr("y1", 0) + .attr("x2", this.#d3XScale(d.date)) + .attr("y2", this.#d3ContainerHeight) + .attr("stroke", tailwindColors.gray[300]) + .attr("stroke-dasharray", "4, 4") + }) + .on("mouseout", () => { + g.selectAll(".guideline").remove() + g.selectAll(".data-point-circle").remove() + tooltip.style("opacity", 0) + }) + } + + #currencyColor(data) { + return { + up: tailwindColors.success, + down: tailwindColors.error, + flat: tailwindColors.gray[500], + }[data.trend.direction] + } + + #currencyValue(data) { + return Intl.NumberFormat(undefined, { + style: "currency", + currency: data.currency || "USD", + }).format(data.value) + } + + #currencyChange(data) { + return Intl.NumberFormat(undefined, { + style: "currency", + currency: data.currency || "USD", + signDisplay: "always", + }).format(data.trend.value.amount) + } + #drawLine() { this.#d3Svg .append("path") @@ -76,18 +241,23 @@ export default class extends Controller { .attr("fill", "none") .attr("stroke", this.#trendColor) .attr("stroke-width", 2) - .attr("d", this.#d3Line); + .attr("d", this.#d3Line) } #createD3Svg() { - const height = this.#d3ContainerHeight - const width = this.#d3ContainerWidth - return this.#d3Container .append("svg") - .attr("width", width) - .attr("height", height) - .attr("viewBox", [ 0, 0, width, height ]) + .attr("width", this.#d3InitialContainerWidth) + .attr("height", this.#d3InitialContainerHeight) + .attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ]) + } + + get #margin() { + if (this.#chartedType === "currency") { + return { top: 20, right: 1, bottom: 30, left: 1 } + } else { + return { top: 0, right: 0, bottom: 0, left: 0 } + } } get #dataPoints() { @@ -98,6 +268,14 @@ export default class extends Controller { this.#_dataPoints = dataPoints } + get #chartedType() { + if (CHARTABLE_TYPES.includes(this.chartedTypeValue)) { + return this.chartedTypeValue + } else { + return "scalar" + } + } + get #d3Svg() { if (this.#_d3Svg) { return this.#_d3Svg @@ -106,12 +284,28 @@ export default class extends Controller { } } + get #d3InitialContainerWidth() { + return this.#_d3InitialContainerWidth + } + + set #d3InitialContainerWidth(value) { + this.#_d3InitialContainerWidth = value + } + + get #d3InitialContainerHeight() { + return this.#_d3InitialContainerHeight + } + + set #d3InitialContainerHeight(value) { + this.#_d3InitialContainerHeight = value + } + get #d3ContainerWidth() { - return this.#d3Container.node().clientWidth + return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right } get #d3ContainerHeight() { - return this.#d3Container.node().clientHeight + return this.#_d3InitialContainerHeight - this.#margin.top - this.#margin.bottom } get #d3Container() { @@ -146,19 +340,26 @@ export default class extends Controller { get #d3XScale() { return d3 .scaleTime() - .rangeRound([0, this.#d3ContainerWidth]) + .rangeRound([ 0, this.#d3ContainerWidth ]) .domain(d3.extent(this.#dataPoints, d => d.date)) } get #d3YScale() { - const PADDING = 0.05 + let percentPadding + + if (this.#chartedType === "currency") { + percentPadding = 0.15 + } else { + percentPadding = 0.05 + } + const dataMin = d3.min(this.#dataPoints, d => d.value) const dataMax = d3.max(this.#dataPoints, d => d.value) - const padding = (dataMax - dataMin) * PADDING + const padding = (dataMax - dataMin) * percentPadding return d3 .scaleLinear() - .rangeRound([this.#d3ContainerHeight, 0]) - .domain([dataMin - padding, dataMax + padding]) + .rangeRound([ this.#d3ContainerHeight, 0 ]) + .domain([ dataMin - padding, dataMax + padding ]) } } diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index 72c43d61..f104f222 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -1,6 +1,11 @@ <%# locals: (series:) %> <% if series %> -
+
<% else %>

No data available for the selected period.