| 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 | })(); |