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..81ce5988 --- /dev/null +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -0,0 +1,135 @@ +import { Controller } from "@hotwired/stimulus" +import tailwindColors from "@maybe/tailwindcolors" +import * as d3 from "d3" + +export default class extends Controller { + static values = { series: Object } + + #_dataPoints = [] + #_d3Svg = null + + 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.#draw() + } + + #normalizeDataPoints() { + this.#dataPoints = this.seriesValue.values.map((d) => ({ + date: new Date(d.date), + value: d.value.amount ? +d.value.amount : +d.value, + })) + } + + #draw() { + this.#d3Svg + .append("path") + .datum(this.#dataPoints) + .attr("fill", "none") + .attr("stroke", this.#trendColor) + .attr("stroke-width", 2) + .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 ]) + } + + get #dataPoints() { + return this.#_dataPoints + } + + set #dataPoints(dataPoints) { + this.#_dataPoints = dataPoints + } + + get #d3Svg() { + if (this.#_d3Svg) { + return this.#_d3Svg + } else { + return this.#_d3Svg = this.#createD3Svg() + } + } + + get #d3ContainerWidth() { + return this.#d3Container.node().clientWidth + } + + get #d3ContainerHeight() { + return this.#d3Container.node().clientHeight + } + + 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.seriesValue.trend.direction + } + + get #favorableDirection() { + return this.seriesValue.trend.favorableDirection + } + + 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.#dataPoints, d => d.date)) + } + + get #d3YScale() { + const PADDING = 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 + + return d3 + .scaleLinear() + .rangeRound([this.#d3ContainerHeight, 0]) + .domain([dataMin - padding, dataMax + padding]) + } +} 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..d6845de3 100644 --- a/app/views/accounts/summary.html.erb +++ b/app/views/accounts/summary.html.erb @@ -17,11 +17,9 @@ } %>
+ data-controller="time-series-chart" + class="h-full w-2/5" + data-time-series-chart-series-value="<%= @asset_series.to_json %>">