From 49b603f47805673a50f24784a829ec4aa4fd537a Mon Sep 17 00:00:00 2001
From: Jose Farias <31393016+josefarias@users.noreply.github.com>
Date: Mon, 22 Apr 2024 11:44:26 -0600
Subject: [PATCH] Flesh out D3 time series charts (#657)
* Reindent TimeSeries classes
* Fix spacing in time series tests
* Remove trend tests where current is nil
I think if we've gotten this far with a nil value for current, there's a data integrity problem.
If we allow this, we'll have to be very defensive in our code. Best to raise and fix early.
* Reindent Money class
* Refactor TimeSeries artifacts
* Use as_json in TimeSeries
* Bring back tests for trends where current is nil
* Bring back trend test
* Correctly enumerate trend test
* Use favorable_direction for trend_styles helper
* Make trend public in TimeSeries::Value
* Allow nil current values in trends
I think I might've gotten it wrong before, nils might appear in trends if values are unavailable for snapshots
* Clean up TimeSeries::Trend
* Skip trend values same class validations if any values are nil
* Refactor Money
* Remove object parsing in TimeSeries::Value
We're only every passing hashes
* Refactor trendline chart controller into a time series chart controller
* Replace trendline controller
* Implement empty state
* Port line-chart controller into time-series-chart
* Split out methods
* Group similar time series chart functionality
* Fix indicator color
* Fix empty state in time series chart
* Replace line-chart controller with time-series-chart controller
* Draw empty time series chart if less than 2 data points
* Fix favorable direction serialization
* Handle integers as well as money
* Fix favorable direction serialization
* Replace chart types with optional elements
* Prevent double-renders when displaying turbo caches of time series charts
* Remove ambiguities between time series and series data
* Improve time series chart property names
* Clean up tooltip template
* Match tooltip designs
* Apply trendline gradient
* Implement trendline split behavior
* Use same stroke width on all trend lines
* Sort time series data by date
* Support percentages
* Use data color for guideline circles
* Revert "Use data color for guideline circles"
This reverts commit f239a1e00f84ae28e32f48315d67cf990e541a8a.
* Use expected defaults for time series chart
* Include day in time-series chart x-axis labels
* favorableDirection -> favorable_direction
* data -> datum where appropriate
* Hide change data in tooltip for percentages
---
.../controllers/line_chart_controller.js | 270 ---------
.../time_series_chart_controller.js | 518 ++++++++++++++++++
.../controllers/trendline_controller.js | 97 ----
app/models/time_series.rb | 2 +-
app/models/time_series/value.rb | 2 +-
app/views/accounts/summary.html.erb | 20 +-
app/views/pages/dashboard.html.erb | 28 +-
.../pages/dashboard/_net_worth_chart.html.erb | 6 +-
app/views/shared/_line_chart.html.erb | 6 +-
9 files changed, 556 insertions(+), 393 deletions(-)
delete mode 100644 app/javascript/controllers/line_chart_controller.js
create mode 100644 app/javascript/controllers/time_series_chart_controller.js
delete mode 100644 app/javascript/controllers/trendline_controller.js
diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js
deleted file mode 100644
index e182f3f2..00000000
--- a/app/javascript/controllers/line_chart_controller.js
+++ /dev/null
@@ -1,270 +0,0 @@
-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 = () => {
- const data = this.prepareData(this.seriesValue);
- this.drawChart(data);
- };
-
- trendStyles(trendDirection) {
- return {
- up: {
- icon: "↑",
- color: tailwindColors.success,
- },
- down: {
- icon: "↓",
- color: tailwindColors.error,
- },
- flat: {
- icon: "→",
- color: tailwindColors.gray[500],
- },
- }[trendDirection];
- }
-
- prepareData(series) {
- return series.values.map((b) => ({
- date: new Date(b.date + "T00:00:00"),
- value: +b.value.amount,
- styles: this.trendStyles(b.trend.direction),
- trend: b.trend,
- formatted: {
- value: Intl.NumberFormat(undefined, {
- style: "currency",
- currency: b.value.currency || "USD",
- }).format(b.value.amount),
- change: Intl.NumberFormat(undefined, {
- style: "currency",
- currency: b.value.currency || "USD",
- signDisplay: "always",
- }).format(b.trend.value.amount),
- },
- }));
- }
-
- drawChart(data) {
- 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;");
-
- if (data.length === 1) {
- this.renderEmpty(svg, initialDimensions);
- return;
- }
-
- 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(
- `
${d3.timeFormat("%b %d, %Y")(d.date)}
-
-
- ${d.formatted.value} ${d.formatted.change} (${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", x(d.date))
- .attr("y1", 0)
- .attr("x2", x(d.date))
- .attr("y2", height)
- .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);
- });
- }
-
- // Dot in middle of chart as placeholder for empty chart
- renderEmpty(svg, { width, height }) {
- svg
- .append("line")
- .attr("x1", width / 2)
- .attr("y1", 0)
- .attr("x2", width / 2)
- .attr("y2", height)
- .attr("stroke", tailwindColors.gray[300])
- .attr("stroke-dasharray", "4, 4");
-
- svg
- .append("circle")
- .attr("cx", width / 2)
- .attr("cy", height / 2)
- .attr("r", 4)
- .style("fill", tailwindColors.gray[400]);
-
- svg.selectAll(".tick").remove();
- svg.selectAll(".domain").remove();
- }
-}
diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js
new file mode 100644
index 00000000..a429ae99
--- /dev/null
+++ b/app/javascript/controllers/time_series_chart_controller.js
@@ -0,0 +1,518 @@
+import { Controller } from "@hotwired/stimulus"
+import tailwindColors from "@maybe/tailwindcolors"
+import * as d3 from "d3"
+
+export default class extends Controller {
+ static values = {
+ data: Object,
+ useLabels: { type: Boolean, default: true },
+ useTooltip: { type: Boolean, default: true },
+ usePercentSign: Boolean
+ }
+
+ #d3SvgMemo = null
+ #d3GroupMemo = null
+ #d3Tooltip = null
+ #d3InitialContainerWidth = 0
+ #d3InitialContainerHeight = 0
+ #normalDataPoints = []
+
+ connect() {
+ this.#install()
+ document.addEventListener("turbo:load", this.#reinstall)
+ }
+
+ disconnect() {
+ this.#teardown()
+ document.removeEventListener("turbo:load", this.#reinstall)
+ }
+
+
+ #reinstall = () => {
+ this.#teardown()
+ this.#install()
+ }
+
+ #teardown() {
+ this.#d3SvgMemo = null
+ this.#d3GroupMemo = null
+ this.#d3Tooltip = null
+ this.#normalDataPoints = []
+
+ this.#d3Container.selectAll("*").remove()
+ }
+
+ #install() {
+ this.#normalizeDataPoints()
+ this.#rememberInitialContainerSize()
+ this.#draw()
+ }
+
+
+ #normalizeDataPoints() {
+ this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({
+ ...d,
+ date: new Date(d.date),
+ value: d.value.amount ? +d.value.amount : +d.value,
+ currency: d.value.currency
+ }))
+ }
+
+
+ #rememberInitialContainerSize() {
+ this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
+ this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
+ }
+
+
+ #draw() {
+ if (this.#normalDataPoints.length < 2) {
+ this.#drawEmpty()
+ } else {
+ this.#drawChart()
+ }
+ }
+
+
+ #drawEmpty() {
+ this.#d3Svg.selectAll(".tick").remove()
+ this.#d3Svg.selectAll(".domain").remove()
+
+ this.#drawDashedLineEmptyState()
+ this.#drawCenteredCircleEmptyState()
+ }
+
+ #drawDashedLineEmptyState() {
+ 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")
+ }
+
+ #drawCenteredCircleEmptyState() {
+ this.#d3Svg
+ .append("circle")
+ .attr("cx", this.#d3InitialContainerWidth / 2)
+ .attr("cy", this.#d3InitialContainerHeight / 2)
+ .attr("r", 4)
+ .style("fill", tailwindColors.gray[400])
+ }
+
+
+ #drawChart() {
+ this.#drawTrendline()
+
+ if (this.useLabelsValue) {
+ this.#drawXAxisLabels()
+ this.#drawGradientBelowTrendline()
+ }
+
+ if (this.useTooltipValue) {
+ this.#drawTooltip()
+ this.#trackMouseForShowingTooltip()
+ }
+ }
+
+ #drawTrendline() {
+ this.#installTrendlineSplit()
+
+ this.#d3Group
+ .append("path")
+ .datum(this.#normalDataPoints)
+ .attr("fill", "none")
+ .attr("stroke", `url(#${this.element.id}-split-gradient)`)
+ .attr("d", this.#d3Line)
+ .attr("stroke-linejoin", "round")
+ .attr("stroke-linecap", "round")
+ .attr("stroke-width", 2)
+ }
+
+ #installTrendlineSplit() {
+ const gradient = this.#d3Svg
+ .append("defs")
+ .append("linearGradient")
+ .attr("id", `${this.element.id}-split-gradient`)
+ .attr("gradientUnits", "userSpaceOnUse")
+ .attr("x1", this.#d3XScale.range()[0])
+ .attr("x2", this.#d3XScale.range()[1])
+
+ gradient.append("stop")
+ .attr("class", "start-color")
+ .attr("offset", "0%")
+ .attr("stop-color", this.#trendColor)
+
+ gradient.append("stop")
+ .attr("class", "middle-color")
+ .attr("offset", "100%")
+ .attr("stop-color", this.#trendColor)
+
+ gradient.append("stop")
+ .attr("class", "end-color")
+ .attr("offset", "100%")
+ .attr("stop-color", tailwindColors.gray[300])
+ }
+
+ #setTrendlineSplitAt(percent) {
+ this.#d3Svg
+ .select(`#${this.element.id}-split-gradient`)
+ .select(".middle-color")
+ .attr("offset", `${percent * 100}%`)
+
+ this.#d3Svg
+ .select(`#${this.element.id}-split-gradient`)
+ .select(".end-color")
+ .attr("offset", `${percent * 100}%`)
+
+ this.#d3Svg
+ .select(`#${this.element.id}-trendline-gradient-rect`)
+ .attr("width", this.#d3ContainerWidth * percent)
+ }
+
+ #drawXAxisLabels() {
+ // Add ticks
+ this.#d3Group
+ .append("g")
+ .attr("transform", `translate(0,${this.#d3ContainerHeight})`)
+ .call(
+ d3
+ .axisBottom(this.#d3XScale)
+ .tickValues([ this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date ])
+ .tickSize(0)
+ .tickFormat(d3.timeFormat("%d %b %Y"))
+ )
+ .select(".domain")
+ .remove()
+
+ // Style ticks
+ this.#d3Group.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")
+ }
+
+ #drawGradientBelowTrendline() {
+ // Define gradient
+ const gradient = this.#d3Group
+ .append("defs")
+ .append("linearGradient")
+ .attr("id", `${this.element.id}-trendline-gradient`)
+ .attr("gradientUnits", "userSpaceOnUse")
+ .attr("x1", 0)
+ .attr("x2", 0)
+ .attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value)))
+ .attr("y2", this.#d3ContainerHeight)
+
+ gradient
+ .append("stop")
+ .attr("offset", 0)
+ .attr("stop-color", this.#trendColor)
+ .attr("stop-opacity", 0.06)
+
+ gradient
+ .append("stop")
+ .attr("offset", 0.5)
+ .attr("stop-color", this.#trendColor)
+ .attr("stop-opacity", 0)
+
+ // Clip path makes gradient start at the trendline
+ this.#d3Group
+ .append("clipPath")
+ .attr("id", `${this.element.id}-clip-below-trendline`)
+ .append("path")
+ .datum(this.#normalDataPoints)
+ .attr("d", d3.area()
+ .x(d => this.#d3XScale(d.date))
+ .y0(this.#d3ContainerHeight)
+ .y1(d => this.#d3YScale(d.value))
+ )
+
+ // Apply the gradient + clip path
+ this.#d3Group
+ .append("rect")
+ .attr("id", `${this.element.id}-trendline-gradient-rect`)
+ .attr("width", this.#d3ContainerWidth)
+ .attr("height", this.#d3ContainerHeight)
+ .attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`)
+ .style("fill", `url(#${this.element.id}-trendline-gradient)`)
+ }
+
+
+ #drawTooltip() {
+ this.#d3Tooltip = d3
+ .select(`#${this.element.id}`)
+ .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
+ }
+
+ #trackMouseForShowingTooltip() {
+ const bisectDate = d3.bisector(d => d.date).left
+
+ this.#d3Group
+ .append("rect")
+ .attr("width", this.#d3ContainerWidth)
+ .attr("height", this.#d3ContainerHeight)
+ .attr("fill", "none")
+ .attr("pointer-events", "all")
+ .on("mousemove", (event) => {
+ const estimatedTooltipWidth = 250
+ const pageWidth = document.body.clientWidth
+ const tooltipX = event.pageX + 10
+ const overflowX = tooltipX + estimatedTooltipWidth - pageWidth
+ const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX
+
+ const [xPos] = d3.pointer(event)
+ const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1)
+ const d0 = this.#normalDataPoints[x0 - 1]
+ const d1 = this.#normalDataPoints[x0]
+ const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0
+ const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth
+
+ this.#setTrendlineSplitAt(xPercent)
+
+ // Reset
+ this.#d3Group.selectAll(".data-point-circle").remove()
+ this.#d3Group.selectAll(".guideline").remove()
+
+ // Guideline
+ this.#d3Group
+ .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")
+
+ // Big circle
+ this.#d3Group
+ .append("circle")
+ .attr("class", "data-point-circle")
+ .attr("cx", this.#d3XScale(d.date))
+ .attr("cy", this.#d3YScale(d.value))
+ .attr("r", 8)
+ .attr("fill", this.#trendColor)
+ .attr("fill-opacity", "0.1")
+ .attr("pointer-events", "none")
+
+ // Small circle
+ this.#d3Group
+ .append("circle")
+ .attr("class", "data-point-circle")
+ .attr("cx", this.#d3XScale(d.date))
+ .attr("cy", this.#d3YScale(d.value))
+ .attr("r", 3)
+ .attr("fill", this.#trendColor)
+ .attr("pointer-events", "none")
+
+ // Render tooltip
+ this.#d3Tooltip
+ .html(this.#tooltipTemplate(d))
+ .style("opacity", 1)
+ .style("left", adjustedX + "px")
+ .style("top", event.pageY - 10 + "px")
+ })
+ .on("mouseout", (event) => {
+ const hoveringOnGuideline = event.toElement?.classList.contains("guideline")
+
+ if (!hoveringOnGuideline) {
+ this.#d3Group.selectAll(".guideline").remove()
+ this.#d3Group.selectAll(".data-point-circle").remove()
+ this.#d3Tooltip.style("opacity", 0)
+
+ this.#setTrendlineSplitAt(1)
+ }
+ })
+ }
+
+ #tooltipTemplate(datum) {
+ return(`
+
+ ${d3.timeFormat("%b %d, %Y")(datum.date)}
+
+
+
+
+
+
+ ${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
+
+
+ ${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? `
+
+ ` : `
+
+ ${this.#tooltipChange(datum)} (${datum.trend.percent}%)
+
+ `}
+
+ `)
+ }
+
+ #tooltipTrendColor(datum) {
+ return {
+ up: tailwindColors.success,
+ down: tailwindColors.error,
+ flat: tailwindColors.gray[500],
+ }[datum.trend.direction]
+ }
+
+ #tooltipValue(datum) {
+ if (datum.currency) {
+ return this.#currencyValue(datum)
+ } else {
+ return datum.value
+ }
+ }
+
+ #tooltipChange(datum) {
+ if (datum.currency) {
+ return this.#currencyChange(datum)
+ } else {
+ return this.#decimalChange(datum)
+ }
+ }
+
+ #currencyValue(datum) {
+ return Intl.NumberFormat(undefined, {
+ style: "currency",
+ currency: datum.currency,
+ }).format(datum.value)
+ }
+
+ #currencyChange(datum) {
+ return Intl.NumberFormat(undefined, {
+ style: "currency",
+ currency: datum.currency,
+ signDisplay: "always",
+ }).format(datum.trend.value.amount)
+ }
+
+ #decimalChange(datum) {
+ return Intl.NumberFormat(undefined, {
+ style: "decimal",
+ signDisplay: "always",
+ }).format(datum.trend.value)
+ }
+
+
+ #createMainSvg() {
+ return this.#d3Container
+ .append("svg")
+ .attr("width", this.#d3InitialContainerWidth)
+ .attr("height", this.#d3InitialContainerHeight)
+ .attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ])
+ }
+
+ #createMainGroup() {
+ return this.#d3Svg
+ .append("g")
+ .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
+ }
+
+
+ get #d3Svg() {
+ if (this.#d3SvgMemo) {
+ return this.#d3SvgMemo
+ } else {
+ return this.#d3SvgMemo = this.#createMainSvg()
+ }
+ }
+
+ get #d3Group() {
+ if (this.#d3GroupMemo) {
+ return this.#d3GroupMemo
+ } else {
+ return this.#d3GroupMemo = this.#createMainGroup()
+ }
+ }
+
+ get #margin() {
+ if (this.useLabelsValue) {
+ return { top: 20, right: 0, bottom: 30, left: 0 }
+ } else {
+ return { top: 0, right: 0, bottom: 0, left: 0 }
+ }
+ }
+
+ get #d3ContainerWidth() {
+ return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right
+ }
+
+ get #d3ContainerHeight() {
+ return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom
+ }
+
+ get #d3Container() {
+ return d3.select(this.element)
+ }
+
+ get #trendColor() {
+ if (this.#trendDirection === "flat") {
+ return tailwindColors.gray[500]
+ } else if (this.#trendDirection === this.#favorableDirection) {
+ return tailwindColors.green[500]
+ } else {
+ return tailwindColors.error
+ }
+ }
+
+ get #trendDirection() {
+ return this.dataValue.trend.direction
+ }
+
+ get #favorableDirection() {
+ return this.dataValue.trend.favorable_direction
+ }
+
+ get #d3Line() {
+ return d3
+ .line()
+ .x(d => this.#d3XScale(d.date))
+ .y(d => this.#d3YScale(d.value))
+ }
+
+ get #d3XScale() {
+ return d3
+ .scaleTime()
+ .rangeRound([ 0, this.#d3ContainerWidth ])
+ .domain(d3.extent(this.#normalDataPoints, d => d.date))
+ }
+
+ get #d3YScale() {
+ const reductionPercent = this.useLabelsValue ? 0.15 : 0.05
+ const dataMin = d3.min(this.#normalDataPoints, d => d.value)
+ const dataMax = d3.max(this.#normalDataPoints, d => d.value)
+ const padding = (dataMax - dataMin) * reductionPercent
+
+ return d3
+ .scaleLinear()
+ .rangeRound([ this.#d3ContainerHeight, 0 ])
+ .domain([ dataMin - padding, dataMax + padding ])
+ }
+}
diff --git a/app/javascript/controllers/trendline_controller.js b/app/javascript/controllers/trendline_controller.js
deleted file mode 100644
index 3b2bb29e..00000000
--- a/app/javascript/controllers/trendline_controller.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import { Controller } from "@hotwired/stimulus";
-import tailwindColors from "@maybe/tailwindcolors";
-import * as d3 from "d3";
-
-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 = () => {
- const data = this.prepareData(this.seriesValue);
- this.drawChart(data);
- };
-
- prepareData(series) {
- return series.values.map((d) => ({
- date: new Date(d.date + "T00:00:00"),
- value: d.value.amount ? +d.value.amount : +d.value,
- }));
- }
-
- drawChart(data) {
- const chartContainer = d3.select(this.element);
- chartContainer.selectAll("*").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: 0, right: 0, bottom: 0, left: 0 };
- const width = initialDimensions.width - margin.left - margin.right;
- const height = initialDimensions.height - margin.top - margin.bottom;
-
- const isLiability = this.classificationValue === "liability";
- const trendDirection = data[data.length - 1].value - data[0].value;
- let lineColor;
-
- if (trendDirection > 0) {
- lineColor = isLiability
- ? tailwindColors.error
- : tailwindColors.green[500];
- } else if (trendDirection < 0) {
- lineColor = isLiability
- ? tailwindColors.green[500]
- : tailwindColors.error;
- } else {
- lineColor = tailwindColors.gray[500];
- }
-
- const xScale = d3
- .scaleTime()
- .rangeRound([0, width])
- .domain(d3.extent(data, (d) => d.date));
-
- const PADDING = 0.05;
- const dataMin = d3.min(data, (d) => d.value);
- const dataMax = d3.max(data, (d) => d.value);
- const padding = (dataMax - dataMin) * PADDING;
-
- const yScale = d3
- .scaleLinear()
- .rangeRound([height, 0])
- .domain([dataMin - padding, dataMax + padding]);
-
- const line = d3
- .line()
- .x((d) => xScale(d.date))
- .y((d) => yScale(d.value));
-
- svg
- .append("path")
- .datum(data)
- .attr("fill", "none")
- .attr("stroke", lineColor)
- .attr("stroke-width", 2)
- .attr("d", line);
- }
-}
diff --git a/app/models/time_series.rb b/app/models/time_series.rb
index 14342a13..9610eb5d 100644
--- a/app/models/time_series.rb
+++ b/app/models/time_series.rb
@@ -15,7 +15,7 @@ class TimeSeries
def initialize(data, favorable_direction: "up")
@favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry
- @values = initialize_values data
+ @values = initialize_values data.sort_by { |d| d[:date] }
end
def first
diff --git a/app/models/time_series/value.rb b/app/models/time_series/value.rb
index e691159d..c19c3c97 100644
--- a/app/models/time_series/value.rb
+++ b/app/models/time_series/value.rb
@@ -22,7 +22,7 @@ class TimeSeries::Value
def as_json
{
- date: date,
+ date: date.iso8601,
value: value.as_json,
trend: trend.as_json
}
diff --git a/app/views/accounts/summary.html.erb b/app/views/accounts/summary.html.erb
index 892ba587..ed519094 100644
--- a/app/views/accounts/summary.html.erb
+++ b/app/views/accounts/summary.html.erb
@@ -17,11 +17,11 @@
} %>
+ id="assetsChart"
+ class="h-full w-2/5"
+ data-controller="time-series-chart"
+ data-time-series-chart-data-value="<%= @asset_series.to_json %>"
+ data-time-series-chart-use-labels-value="false">
@@ -34,11 +34,11 @@
} %>
+ id="liabilitiesChart"
+ class="h-full w-2/5"
+ data-controller="time-series-chart"
+ data-time-series-chart-data-value="<%= @liability_series.to_json %>"
+ data-time-series-chart-use-labels-value="false">
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb
index 6aaee6b9..fa6e16a9 100644
--- a/app/views/pages/dashboard.html.erb
+++ b/app/views/pages/dashboard.html.erb
@@ -43,10 +43,11 @@
} %>
+ data-controller="time-series-chart"
+ data-time-series-chart-data-value="<%= @income_series.to_json %>"
+ data-time-series-chart-use-labels-value="false">
@@ -60,10 +61,11 @@
} %>
+ data-controller="time-series-chart"
+ data-time-series-chart-data-value="<%= @spending_series.to_json %>"
+ data-time-series-chart-use-labels-value="false">
@@ -78,10 +80,11 @@
} %>
+ data-controller="time-series-chart"
+ data-time-series-chart-data-value="<%= @savings_rate_series.to_json %>"
+ data-time-series-chart-use-labels-value="false">
@@ -95,10 +98,11 @@
} %>
+ data-controller="time-series-chart"
+ data-time-series-chart-data-value="<%= @investing_series.to_json %>"
+ data-time-series-chart-use-labels-value="false">
diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb
index 72c43d61..c6c04d55 100644
--- a/app/views/pages/dashboard/_net_worth_chart.html.erb
+++ b/app/views/pages/dashboard/_net_worth_chart.html.erb
@@ -1,6 +1,10 @@
<%# locals: (series:) %>
<% if series %>
-
+
<% else %>
No data available for the selected period.
diff --git a/app/views/shared/_line_chart.html.erb b/app/views/shared/_line_chart.html.erb
index 72c43d61..d72f5564 100644
--- a/app/views/shared/_line_chart.html.erb
+++ b/app/views/shared/_line_chart.html.erb
@@ -1,6 +1,10 @@
<%# locals: (series:) %>
<% if series %>
-
+
<% else %>
No data available for the selected period.