diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js
index 8db9b186..0eab0bd8 100644
--- a/app/javascript/controllers/time_series_chart_controller.js
+++ b/app/javascript/controllers/time_series_chart_controller.js
@@ -2,13 +2,15 @@ import { Controller } from "@hotwired/stimulus"
import tailwindColors from "@maybe/tailwindcolors"
import * as d3 from "d3"
-const CHARTABLE_TYPES = [ "scalar", "currency" ]
+const CHART_TYPES = [ "mini", "full" ]
export default class extends Controller {
- static values = { series: Object, chartedType: String }
+ static values = { series: Object, chartType: String }
#_dataPoints = []
#_d3Svg = null
+ #_d3FullChartGroup = null
+ #_d3Tooltip = null
#_d3InitialContainerWidth = 0
#_d3InitialContainerHeight = 0
@@ -34,8 +36,7 @@ export default class extends Controller {
#install() {
this.#normalizeDataPoints()
- this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
- this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
+ this.#rememberInitialContainerSize()
this.#draw()
}
@@ -48,11 +49,16 @@ export default class extends Controller {
}))
}
+ #rememberInitialContainerSize() {
+ this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
+ this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
+ }
+
#draw() {
if (this.#dataPoints.length < 2) {
this.#drawEmpty()
- } else if (this.#chartedType === "currency") {
- this.#drawCurrency()
+ } else if (this.#isFullChart) {
+ this.#drawFullChart()
} else {
this.#drawLine()
}
@@ -62,6 +68,11 @@ export default class extends Controller {
this.#d3Svg.selectAll(".tick").remove()
this.#d3Svg.selectAll(".domain").remove()
+ this.#drawDashedLine()
+ this.#drawCenteredCircle()
+ }
+
+ #drawDashedLine() {
this.#d3Svg
.append("line")
.attr("x1", this.#d3InitialContainerWidth / 2)
@@ -70,7 +81,9 @@ export default class extends Controller {
.attr("y2", this.d3InitialContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
+ }
+ #drawCenteredCircle() {
this.#d3Svg
.append("circle")
.attr("cx", this.#d3InitialContainerWidth / 2)
@@ -79,13 +92,16 @@ export default class extends Controller {
.style("fill", tailwindColors.gray[400])
}
- #drawCurrency() {
- const g = this.#d3Svg
- .append("g")
- .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
+ #drawFullChart() {
+ this.#drawXAxisLabels()
+ this.#drawLine()
+ this.#drawTooltip()
+ this.#trackMouseForShowingTooltip()
+ }
- // X-Axis labels
- g.append("g")
+ #drawXAxisLabels() {
+ // Add ticks
+ this.#d3FullChartGroup.append("g")
.attr("transform", `translate(0,${this.#d3ContainerHeight})`)
.call(
d3
@@ -97,7 +113,8 @@ export default class extends Controller {
.select(".domain")
.remove()
- g.selectAll(".tick text")
+ // Style ticks
+ this.#d3FullChartGroup.selectAll(".tick text")
.style("fill", tailwindColors.gray[500])
.style("font-size", "12px")
.style("font-weight", "500")
@@ -107,111 +124,9 @@ export default class extends Controller {
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) {
+ #dataTrendColor(data) {
return {
up: tailwindColors.success,
down: tailwindColors.error,
@@ -235,13 +150,113 @@ export default class extends Controller {
}
#drawLine() {
- this.#d3Svg
+ let container
+
+ if (this.#isFullChart) {
+ container = this.#d3FullChartGroup
+ } else {
+ container = this.#d3Svg
+ }
+
+ const line = container
.append("path")
.datum(this.#dataPoints)
.attr("fill", "none")
.attr("stroke", this.#trendColor)
- .attr("stroke-width", 2)
.attr("d", this.#d3Line)
+
+ if (this.#isFullChart) {
+ line
+ .attr("stroke-linejoin", "round")
+ .attr("stroke-linecap", "round")
+ .attr("stroke-width", 1.5)
+ .attr("class", "line-chart-path")
+ } else {
+ line
+ .attr("stroke-width", 2)
+ }
+ }
+
+ #drawTooltip() {
+ this.#d3Tooltip = 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
+ }
+
+ #trackMouseForShowingTooltip() {
+ const bisectDate = d3.bisector(d => d.date).left
+
+ this.#d3FullChartGroup.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.#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
+
+ // Reset
+ this.#d3FullChartGroup.selectAll(".data-point-circle").remove()
+ this.#d3FullChartGroup.selectAll(".guideline").remove()
+
+ // Big circle
+ this.#d3FullChartGroup.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")
+
+ // Small circle
+ this.#d3FullChartGroup.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")
+
+ // Guideline
+ this.#d3FullChartGroup.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")
+
+ // Render tooltip
+ this.#d3Tooltip
+ .html(this.#tooltipTemplate(d))
+ .style("opacity", 1)
+ .style("left", adjustedX + "px")
+ .style("top", event.pageY - 10 + "px")
+ })
+ .on("mouseout", () => {
+ this.#d3FullChartGroup.selectAll(".guideline").remove()
+ this.#d3FullChartGroup.selectAll(".data-point-circle").remove()
+ this.#d3Tooltip.style("opacity", 0)
+ })
}
#createD3Svg() {
@@ -252,8 +267,32 @@ export default class extends Controller {
.attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ])
}
+ #createD3FullChartGroup() {
+ return this.#d3Svg
+ .append("g")
+ .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
+ }
+
+ #tooltipTemplate(data) {
+ return(`
+
+ ${d3.timeFormat("%b %d, %Y")(data.date)}
+
+
+
+ ${this.#currencyValue(data)} ${this.#currencyChange(data)} (${data.trend.percent}%)
+
+ `)
+ }
+
+ get #isFullChart() {
+ return this.#chartType === "full"
+ }
+
get #margin() {
- if (this.#chartedType === "currency") {
+ if (this.#isFullChart) {
return { top: 20, right: 1, bottom: 30, left: 1 }
} else {
return { top: 0, right: 0, bottom: 0, left: 0 }
@@ -268,11 +307,11 @@ export default class extends Controller {
this.#_dataPoints = dataPoints
}
- get #chartedType() {
- if (CHARTABLE_TYPES.includes(this.chartedTypeValue)) {
- return this.chartedTypeValue
+ get #chartType() {
+ if (CHART_TYPES.includes(this.chartTypeValue)) {
+ return this.chartTypeValue
} else {
- return "scalar"
+ return "mini"
}
}
@@ -284,6 +323,14 @@ export default class extends Controller {
}
}
+ get #d3FullChartGroup() {
+ if (this.#_d3FullChartGroup) {
+ return this.#_d3FullChartGroup
+ } else {
+ return this.#_d3FullChartGroup = this.#createD3FullChartGroup()
+ }
+ }
+
get #d3InitialContainerWidth() {
return this.#_d3InitialContainerWidth
}
@@ -300,6 +347,14 @@ export default class extends Controller {
this.#_d3InitialContainerHeight = value
}
+ get #d3Tooltip() {
+ return this.#_d3Tooltip
+ }
+
+ set #d3Tooltip(value) {
+ this.#_d3Tooltip = value
+ }
+
get #d3ContainerWidth() {
return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right
}
@@ -347,7 +402,7 @@ export default class extends Controller {
get #d3YScale() {
let percentPadding
- if (this.#chartedType === "currency") {
+ if (this.#isFullChart) {
percentPadding = 0.15
} else {
percentPadding = 0.05
diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb
index f104f222..78033ee0 100644
--- a/app/views/pages/dashboard/_net_worth_chart.html.erb
+++ b/app/views/pages/dashboard/_net_worth_chart.html.erb
@@ -5,7 +5,7 @@
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-series-value="<%= series.to_json %>"
- data-time-series-chart-charted-type-value="currency">
+ data-time-series-chart-chart-type-value="full">
<% else %>
No data available for the selected period.