apps/habbits/static/chart.js 4.7 K raw
1
// Dependency-free chart rendering for the habit detail page. Reads JSON
2
// points off #habit-chart's data attributes and draws an inline SVG: a line
3
// chart for numeric (int/float) habits, a day-by-day streak heatmap for bool
4
// habits.
5
(function () {
6
  var SVG_NS = "http://www.w3.org/2000/svg";
7
8
  function svgEl(tag, attrs) {
9
    var el = document.createElementNS(SVG_NS, tag);
10
    for (var k in attrs) el.setAttribute(k, attrs[k]);
11
    return el;
12
  }
13
14
  function pad2(n) {
15
    return n < 10 ? "0" + n : "" + n;
16
  }
17
18
  function dayKey(date) {
19
    return date.getFullYear() + "-" + pad2(date.getMonth() + 1) + "-" + pad2(date.getDate());
20
  }
21
22
  function renderLine(el, points, unit) {
23
    var width = Math.max(el.clientWidth || 0, 280);
24
    var height = 180;
25
    var padding = { top: 16, right: 16, bottom: 24, left: 44 };
26
27
    var vals = points.map(function (p) {
28
      return p.v;
29
    });
30
    var min = Math.min.apply(null, vals);
31
    var max = Math.max.apply(null, vals);
32
    if (min === max) {
33
      min -= 1;
34
      max += 1;
35
    }
36
    var pad = (max - min) * 0.1;
37
    min -= pad;
38
    max += pad;
39
40
    var t0 = points[0].t;
41
    var t1 = points[points.length - 1].t;
42
    if (t0 === t1) t1 = t0 + 1;
43
44
    var innerW = width - padding.left - padding.right;
45
    var innerH = height - padding.top - padding.bottom;
46
47
    function x(t) {
48
      return padding.left + ((t - t0) / (t1 - t0)) * innerW;
49
    }
50
    function y(v) {
51
      return padding.top + innerH - ((v - min) / (max - min)) * innerH;
52
    }
53
54
    var svg = svgEl("svg", {
55
      width: width,
56
      height: height,
57
      viewBox: "0 0 " + width + " " + height,
58
      class: "chart-svg",
59
    });
60
61
    [min + pad, (min + max) / 2, max - pad].forEach(function (v) {
62
      var ly = y(v).toFixed(1);
63
      svg.appendChild(
64
        svgEl("line", {
65
          x1: padding.left,
66
          x2: width - padding.right,
67
          y1: ly,
68
          y2: ly,
69
          class: "chart-gridline",
70
        }),
71
      );
72
      var label = svgEl("text", {
73
        x: padding.left - 6,
74
        y: ly,
75
        class: "chart-axis-label",
76
        "text-anchor": "end",
77
        "dominant-baseline": "middle",
78
      });
79
      label.textContent = Number(v.toFixed(2));
80
      svg.appendChild(label);
81
    });
82
83
    var d = points
84
      .map(function (p, i) {
85
        return (i === 0 ? "M" : "L") + x(p.t).toFixed(1) + "," + y(p.v).toFixed(1);
86
      })
87
      .join(" ");
88
    svg.appendChild(svgEl("path", { d: d, class: "chart-line" }));
89
90
    points.forEach(function (p) {
91
      var cx = x(p.t).toFixed(1);
92
      var cy = y(p.v).toFixed(1);
93
      var dot = svgEl("circle", { cx: cx, cy: cy, r: 3, class: "chart-dot" });
94
      var title = svgEl("title", {});
95
      title.textContent = new Date(p.t * 1000).toLocaleString() + ": " + p.v + (unit ? " " + unit : "");
96
      dot.appendChild(title);
97
      svg.appendChild(dot);
98
    });
99
100
    el.appendChild(svg);
101
  }
102
103
  function renderHeatmap(el, points) {
104
    var days = {};
105
    points.forEach(function (p) {
106
      var key = dayKey(new Date(p.t * 1000));
107
      var truthy = p.v === 1;
108
      if (!days[key] || truthy) days[key] = truthy ? 2 : 1;
109
    });
110
111
    var keys = Object.keys(days).sort();
112
    var start = new Date(keys[0] + "T00:00:00");
113
    start.setDate(start.getDate() - start.getDay());
114
    var today = new Date();
115
    today.setHours(0, 0, 0, 0);
116
117
    var totalDays = Math.round((today - start) / 86400000) + 1;
118
    var weeks = Math.ceil(totalDays / 7);
119
    var cell = 11;
120
    var gap = 3;
121
    var step = cell + gap;
122
    var width = weeks * step;
123
    var height = 7 * step;
124
125
    var svg = svgEl("svg", {
126
      width: width,
127
      height: height,
128
      viewBox: "0 0 " + width + " " + height,
129
      class: "chart-svg",
130
    });
131
132
    for (var i = 0; i < totalDays; i++) {
133
      var d = new Date(start);
134
      d.setDate(d.getDate() + i);
135
      var key = dayKey(d);
136
      var status = days[key] || 0;
137
      var week = Math.floor(i / 7);
138
      var dow = i % 7;
139
140
      var rect = svgEl("rect", {
141
        x: week * step,
142
        y: dow * step,
143
        width: cell,
144
        height: cell,
145
        rx: 2,
146
        class: "chart-cell chart-cell-" + status,
147
      });
148
      var title = svgEl("title", {});
149
      title.textContent = key + ": " + (status === 2 ? "true" : status === 1 ? "false" : "no record");
150
      rect.appendChild(title);
151
      svg.appendChild(rect);
152
    }
153
154
    el.appendChild(svg);
155
  }
156
157
  function init() {
158
    var el = document.getElementById("habit-chart");
159
    if (!el) return;
160
161
    var points;
162
    try {
163
      points = JSON.parse(el.dataset.points || "[]");
164
    } catch (e) {
165
      return;
166
    }
167
    if (!points.length) return;
168
169
    if (el.dataset.type === "bool") {
170
      renderHeatmap(el, points);
171
    } else {
172
      renderLine(el, points, el.dataset.unit || "");
173
    }
174
  }
175
176
  init();
177
})();