PageSourceSearch

https://tend.cash/assets/lib/account-trend-line-c16fde35744b5c25f0…e9dfa12c2cefef7845ead69a17050059ce108d22.js

js tend.cash collected 2026-10-04 23:34:41 UTC 4,681 bytes, 129 lines download raw bytes

1import * as d3 from "d3";
2
3const EASE = d3.easeCubicOut;
4
5// Minimal sparkline: no axes, no labels — just an animated line of the
6// account balance over time, sized to fill its container.
7export class AccountTrendLine {
8  constructor(el, color = "primary", type = "asset") {
9    this.el = el;
10    this.color = color;
11    // Liability balances (credit/loan) are stored negative, so flip the
12    // sign: owing more should read as the line climbing, same as an asset
13    // account gaining balance. Keeping this signed (rather than scaling by
14    // magnitude) means a balance that crosses zero — an overdrafted asset
15    // account, or a credit account in credit — still shows as a real dip
16    // instead of reading identically to healthy growth.
17    this.sign = type === "liability" ? -1 : 1;
18  }
19
20  render(points) {
21    this.data = this.parse(points);
22    this.el.innerHTML = "";
23    this.animated = false;
24    this.svg = d3.select(this.el).append("svg").style("width", "100%").style("height", "100%").style("display", "block");
25
26    const gradientId = `trend-gradient-${Math.random().toString(36).slice(2)}`;
27    const defs = this.svg.append("defs");
28    const gradient = defs.append("linearGradient").attr("id", gradientId).attr("x1", 0).attr("x2", 0).attr("y1", 0).attr("y2", 1);
29    gradient.append("stop").attr("offset", "0%").attr("stop-color", `var(--color-${this.color})`).attr("stop-opacity", 0.15);
30    gradient.append("stop").attr("offset", "100%").attr("stop-color", `var(--color-${this.color})`).attr("stop-opacity", 0);
31
32    this.area = this.svg.append("path").attr("fill", `url(#${gradientId})`).attr("stroke", "none");
33    this.path = this.svg
34      .append("path")
35      .attr("fill", "none")
36      .attr("stroke", `var(--color-${this.color})`)
37      .attr("stroke-width", 2)
38      .attr("stroke-linejoin", "round")
39      .attr("stroke-linecap", "round");
40    this.draw();
41  }
42
43  destroy() {
44    this.el.innerHTML = "";
45  }
46
47  parse(points) {
48    return points.map((d) => ({ ...d, date: new Date(`${d.date}T00:00:00`) }));
49  }
50
51  draw() {
52    if (!this.data) return;
53
54    const width = this.el.clientWidth || 1;
55    const height = this.el.clientHeight || 1;
56
57    this.svg.attr("viewBox", `0 0 ${width} ${height}`);
58
59    let d, areaD;
60    if (this.data.length <= 1) {
61      // No transaction history (or a single day of it) — draw a flat line
62      // across the middle instead of a degenerate point.
63      const mid = height / 2;
64      d = `M1,${mid}L${width - 1},${mid}`;
65      areaD = `M1,${mid}L${width - 1},${mid}L${width - 1},${height}L1,${height}Z`;
66    } else {
67      const x = d3
68        .scaleTime()
69        .domain(d3.extent(this.data, (d) => d.date))
70        .range([1, width - 1]);
71
72      // Sign-flip liability balances so the line reads the same way for
73      // every account type: it climbs as the account moves further from
74      // $0 in its normal direction (balance growing for an asset, debt
75      // growing for a liability) and falls as it moves back toward $0.
76      // Staying signed (rather than scaling by magnitude) means a balance
77      // that crosses zero still shows as a real dip below the axis.
78      const value = (d) => this.sign * d.balance;
79      let [minValue, maxValue] = d3.extent(this.data, value);
80      if (minValue === maxValue) {
81        minValue = Math.min(minValue, 0);
82        maxValue = Math.max(maxValue, 1);
83      }
84      const y = d3
85        .scaleLinear()
86        .domain([Math.min(0, minValue), Math.max(0, maxValue)])
87        .nice()
88        .range([height - 1, 1]);
89
90      const line = d3
91        .line()
92        .curve(d3.curveBasis)
93        .x((d) => x(d.date))
94        .y((d) => y(value(d)));
95
96      const area = d3
97        .area()
98        .curve(d3.curveBasis)
99        .x((d) => x(d.date))
100        .y0(y(0))
101        .y1((d) => y(value(d)));
102
103      d = line(this.data);
104      areaD = area(this.data);
105    }
106
107    this.area.interrupt().attr("d", areaD);
108    this.path.interrupt().attr("d", d);
109
110    const length = this.path.node().getTotalLength();
111
112    if (!this.animated) {
113      this.animated = true;
114      this.area.attr("opacity", 0).transition().duration(800).ease(EASE).attr("opacity", 1);
115      this.path
116        .attr("stroke-dasharray", length)
117        .attr("stroke-dashoffset", length)
118        .transition()
119        .duration(800)
120        .ease(EASE)
121        .attr("stroke-dashoffset", 0);
122    } else {
123      // Redraw (e.g. on resize) without replaying the draw-in animation, and
124      // without a stale dasharray length causing a dashed/clipped line.
125      this.area.attr("opacity", 1);
126      this.path.attr("stroke-dasharray", null).attr("stroke-dashoffset", null);
127    }
128  }
129};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.