1
2
3
4
5(function (root) {
6 'use strict';
7
8 var COLORS = {
9 surface: '#1a1a19',
10 grid: '#2c2c2a',
11 baseline: '#383835',
12 muted: '#898781',
13 secondary: '#c3c2b7',
14 primary: '#ffffff',
15 series: '#3987e5',
16 seriesWash: 'rgba(57, 135, 229, 0.10)',
17 warning: '#fab219',
18 critical: '#d03b3b'
19 };
20
21 function setupCanvas(canvas) {
22 var dpr = root.devicePixelRatio || 1;
23 var rect = canvas.getBoundingClientRect();
24 var w = Math.max(10, Math.round(rect.width));
25 var h = Math.max(10, Math.round(rect.height));
26 canvas.width = w * dpr;
27 canvas.height = h * dpr;
28 var ctx = canvas.getContext('2d');
29 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
30 return { ctx: ctx, w: w, h: h };
31 }
32
33
34 function niceScale(maxValue) {
35 if (maxValue <= 0) return { max: 100, step: 25 };
36 var rough = maxValue / 4;
37 var mag = Math.pow(10, Math.floor(Math.log(rough) / Math.LN10));
38 var step = 10 * mag;
39 var ladder = [1, 2, 2.5, 5, 10];
40 for (var i = 0; i < ladder.length; i++) {
41 if (ladder[i] * mag >= rough) { step = ladder[i] * mag; break; }
42 }
43 return { max: Math.ceil(maxValue / step) * step, step: step };
44 }
45
46
47
48
49
50
51 function spendingChart(canvas, tooltipEl, series, opts) {
52 opts = opts || {};
53 var fmtShort = opts.formatShort || function (n) { return '$' + Math.round(n); };
54 var fmtLong = opts.formatLong || fmtShort;
55 var labelFor = opts.monthLabel || function (m) { return m; };
56
57 var pad = { top: 16, right: 20, bottom: 28, left: 48 };
58 var points = [];
59 var geom = null;
60
61 function draw(hoverIndex) {
62 var s = setupCanvas(canvas);
63 var ctx = s.ctx;
64 var plotW = s.w - pad.left - pad.right;
65 var plotH = s.h - pad.top - pad.bottom;
66 var maxSpent = 0;
67 series.forEach(function (d) { if (d.spent > maxSpent) maxSpent = d.spent; });
68 var scale = niceScale(maxSpent);
69
70 ctx.clearRect(0, 0, s.w, s.h);
71 ctx.font = '11px system-ui, -apple-system, "Segoe UI", sans-serif';
72
73
74 ctx.textAlign = 'right';
75 ctx.textBaseline = 'middle';
76 for (var v = 0; v <= scale.max; v += scale.step) {
77 var y = pad.top + plotH - (v / scale.max) * plotH;
78 ctx.strokeStyle = v === 0 ? COLORS.baseline : COLORS.grid;
79 ctx.lineWidth = 1;
80 ctx.beginPath();
81 ctx.moveTo(pad.left, Math.round(y) + 0.5);
82 ctx.lineTo(pad.left + plotW, Math.round(y) + 0.5);
83 ctx.stroke();
84 ctx.fillStyle = COLORS.muted;
85 ctx.fillText(fmtShort(v), pad.left - 8, y);
86 }
87
88
89 points = series.map(function (d, i) {
90 var x = series.length === 1
91 ? pad.left + plotW / 2
92 : pad.left + (i / (series.length - 1)) * plotW;
93 var y = pad.top + plotH - (d.spent / scale.max) * plotH;
94 return { x: x, y: y, d: d };
95 });
96 ctx.textAlign = 'center';
97 ctx.textBaseline = 'top';
98 var labelEvery = Math.ceil(series.length / Math.max(1, Math.floor(plotW / 56)));
99 points.forEach(function (p, i) {
100 if (i % labelEvery !== 0 && i !== points.length - 1) return;
101 if (i !== points.length - 1 && points.length - 1 - i < labelEvery && labelEvery > 1) return;
102 ctx.fillStyle = COLORS.muted;
103 ctx.fillText(labelFor(p.d.month).split(' ')[0], p.x, pad.top + plotH + 8);
104 });
105
106
107 if (points.length > 1) {
108 ctx.beginPath();
109 ctx.moveTo(points[0].x, pad.top + plotH);
110 points.forEach(function (p) { ctx.lineTo(p.x, p.y); });
111 ctx.lineTo(points[points.length - 1].x, pad.top + plotH);
112 ctx.closePath();
113 ctx.fillStyle = COLORS.seriesWash;
114 ctx.fill();
115 }
116
117
118 if (hoverIndex != null && points[hoverIndex]) {
119 var hp = points[hoverIndex];
120 ctx.strokeStyle = COLORS.baseline;
121 ctx.lineWidth = 1;
122 ctx.beginPath();
123 ctx.moveTo(Math.round(hp.x) + 0.5, pad.top);
124 ctx.lineTo(Math.round(hp.x) + 0.5, pad.top + plotH);
125 ctx.stroke();
126 }
127
128
129 if (points.length > 1) {
130 ctx.beginPath();
131 points.forEach(function (p, i) { i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); });
132 ctx.strokeStyle = COLORS.series;
133 ctx.lineWidth = 2;
134 ctx.lineJoin = 'round';
135 ctx.lineCap = 'round';
136 ctx.stroke();
137 }
138
139
140 function marker(p, r) {
141 ctx.beginPath();
142 ctx.arc(p.x, p.y, r + 2, 0, Math.PI * 2);
143 ctx.fillStyle = COLORS.surface;
144 ctx.fill();
145 ctx.beginPath();
146 ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
147 ctx.fillStyle = COLORS.series;
148 ctx.fill();
149 }
150 if (points.length) marker(points[points.length - 1], 4);
151 if (hoverIndex != null && points[hoverIndex]) marker(points[hoverIndex], 5);
152
153
154 if (points.length) {
155 var last = points[points.length - 1];
156 ctx.fillStyle = COLORS.secondary;
157 ctx.textAlign = last.x > pad.left + plotW - 40 ? 'right' : 'left';
158 ctx.textBaseline = 'bottom';
159 ctx.fillText(fmtShort(last.d.spent), last.x + (ctx.textAlign === 'right' ? -8 : 8), last.y - 8);
160 }
161
162 geom = { padLeft: pad.left, plotW: plotW };
163 }
164
165 function nearestIndex(clientX) {
166 if (!points.length) return null;
167 var rect = canvas.getBoundingClientRect();
168 var x = clientX - rect.left;
169 var best = 0;
170 var bestDist = Infinity;
171 points.forEach(function (p, i) {
172 var d = Math.abs(p.x - x);
173 if (d < bestDist) { bestDist = d; best = i; }
174 });
175 return best;
176 }
177
178 function onMove(e) {
179 var idx = nearestIndex(e.clientX);
180 draw(idx);
181 if (idx == null) return;
182 var p = points[idx];
183 tooltipEl.hidden = false;
184 tooltipEl.innerHTML = '<span class="tt-title">' + labelFor(p.d.month) + '</span>' +
185 '<span class="tt-row"><span class="tt-key"></span>Spent <b>' + fmtLong(p.d.spent) + '</b></span>' +
186 '<span class="tt-row tt-dim">Income ' + fmtLong(p.d.income || 0) + '</span>';
187 var rect = canvas.getBoundingClientRect();
188 var host = canvas.parentElement.getBoundingClientRect();
189 var left = rect.left - host.left + p.x + 12;
190 if (p.x > rect.width * 0.62) left = rect.left - host.left + p.x - tooltipEl.offsetWidth - 12;
191 tooltipEl.style.left = left + 'px';
192 tooltipEl.style.top = (rect.top - host.top + Math.max(4, p.y - 40)) + 'px';
193 }
194
195 function onLeave() {
196 tooltipEl.hidden = true;
197 draw(null);
198 }
199
200 if (canvas._chartCleanup) canvas._chartCleanup();
201 canvas.addEventListener('mousemove', onMove);
202 canvas.addEventListener('mouseleave', onLeave);
203 canvas._chartCleanup = function () {
204 canvas.removeEventListener('mousemove', onMove);
205 canvas.removeEventListener('mouseleave', onLeave);
206 };
207
208 draw(null);
209 return { redraw: function () { draw(null); } };
210 }
211
212
213
214
215
216
217 function renderBudgetBars(container, rows, opts) {
218 opts = opts || {};
219 var fmt = opts.formatMoney || function (n) { return '$' + n; };
220 container.innerHTML = '';
221 if (!rows.length) {
222 var empty = document.createElement('p');
223 empty.className = 'empty-note';
224 empty.textContent = 'No budgets configured.';
225 container.appendChild(empty);
226 return;
227 }
228 rows.forEach(function (r) {
229 var pct = Math.min(1, r.pct);
230 var state = r.over ? 'over' : (r.pct >= 0.8 ? 'near' : 'ok');
231 var row = document.createElement('div');
232 row.className = 'budget-row budget-' + state;
233 row.setAttribute('role', 'group');
234 row.setAttribute('aria-label',
235 r.category + ': ' + fmt(r.spent) + ' of ' + fmt(r.budget) + (r.over ? ', over budget' : ''));
236
237 var head = document.createElement('div');
238 head.className = 'budget-head';
239 var name = document.createElement('span');
240 name.className = 'budget-name';
241 name.textContent = r.category;
242 var val = document.createElement('span');
243 val.className = 'budget-val';
244 var pctText = Math.round(r.pct * 100) + '%';
245 if (r.over) {
246 val.innerHTML = '<span class="badge-over" title="Over budget">! over by ' +
247 fmt(Math.round((r.spent - r.budget) * 100) / 100) + '</span> ' +
248 '<b>' + fmt(r.spent) + '</b> / ' + fmt(r.budget);
249 } else {
250 val.innerHTML = '<span class="budget-pct">' + pctText + '</span> <b>' +
251 fmt(r.spent) + '</b> / ' + fmt(r.budget);
252 }
253 head.appendChild(name);
254 head.appendChild(val);
255
256 var track = document.createElement('div');
257 track.className = 'budget-track';
258 var fill = document.createElement('div');
259 fill.className = 'budget-fill';
260 fill.style.width = (pct * 100).toFixed(1) + '%';
261 track.appendChild(fill);
262
263 row.appendChild(head);
264 row.appendChild(track);
265
266 row.title = r.category + ' — spent ' + fmt(r.spent) + ' of ' + fmt(r.budget) +
267 (r.over ? ' (over by ' + fmt(r.spent - r.budget) + ')' : ' (' + fmt(r.remaining) + ' left)');
268 if (opts.onEdit) {
269 row.classList.add('budget-editable');
270 row.addEventListener('click', function () { opts.onEdit(r); });
271 }
272 container.appendChild(row);
273 });
274 }
275
276 root.Charts = {
277 COLORS: COLORS,
278 niceScale: niceScale,
279 spendingChart: spendingChart,
280 renderBudgetBars: renderBudgetBars
281 };
282})(typeof self !== 'undefined' ? self : this);
283
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.