1
2
3
4
5
6
7
8
9(function (global, factory) {
10 if (typeof module === 'object' && module.exports) {
11 module.exports = factory();
12 } else {
13 global.FinanceCharts = factory();
14 }
15})(typeof self !== 'undefined' ? self : this, function () {
16 'use strict';
17
18 var MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
19
20
21 function niceTicks(maxValue, targetCount) {
22 targetCount = targetCount || 4;
23 if (typeof maxValue !== 'number' || !isFinite(maxValue) || maxValue <= 0) {
24 return { top: 100, step: 25, ticks: [0, 25, 50, 75, 100] };
25 }
26 var raw = maxValue / targetCount;
27 var mag = Math.pow(10, Math.floor(Math.log10(raw)));
28 var norm = raw / mag;
29 var stepN = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
30 var step = stepN * mag;
31 var top = Math.ceil(maxValue / step - 1e-9) * step;
32
33 top = Math.round(top * 1e6) / 1e6;
34 step = Math.round(step * 1e6) / 1e6;
35 var ticks = [];
36 for (var v = 0; v <= top + step / 1e6; v += step) {
37 ticks.push(Math.round(v * 1e6) / 1e6);
38 }
39 return { top: top, step: step, ticks: ticks };
40 }
41
42
43 function monthTickLabel(key, withYear) {
44 var m = parseInt(String(key).slice(5, 7), 10);
45 var name = MONTH_NAMES[m - 1] || '?';
46 return withYear ? name + ' ’' + String(key).slice(2, 4) : name;
47 }
48
49 var DEFAULT_COLORS = {
50 line: '#3987e5',
51 surface: '#1a1a19',
52 grid: '#2c2c2a',
53 axis: '#383835',
54 text: '#ffffff',
55 muted: '#898781'
56 };
57
58 var FONT = '11px system-ui, -apple-system, "Segoe UI", sans-serif';
59 var LABEL_FONT = '600 12px system-ui, -apple-system, "Segoe UI", sans-serif';
60
61
62
63 function drawSpendingChart(canvas, data, opts) {
64 if (!canvas || typeof canvas.getContext !== 'function') { return null; }
65 var ctx = canvas.getContext('2d');
66 if (!ctx) { return null; }
67 opts = opts || {};
68 var colors = {};
69 for (var k in DEFAULT_COLORS) {
70 colors[k] = (opts.colors && opts.colors[k]) || DEFAULT_COLORS[k];
71 }
72 var formatValue = opts.formatValue || function (v) { return String(v); };
73 var formatTick = opts.formatTick || formatValue;
74 var hoverIndex = typeof opts.hoverIndex === 'number' ? opts.hoverIndex : null;
75
76 var dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
77 var cssW = canvas.clientWidth || canvas.width || 600;
78 var cssH = canvas.clientHeight || canvas.height || 280;
79 var pxW = Math.max(1, Math.round(cssW * dpr));
80 var pxH = Math.max(1, Math.round(cssH * dpr));
81 if (canvas.width !== pxW || canvas.height !== pxH) {
82 canvas.width = pxW;
83 canvas.height = pxH;
84 }
85 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
86 ctx.clearRect(0, 0, cssW, cssH);
87
88 if (!data || !data.length) {
89 ctx.font = FONT;
90 ctx.fillStyle = colors.muted;
91 ctx.textAlign = 'center';
92 ctx.textBaseline = 'middle';
93 ctx.fillText('No transactions in this range', cssW / 2, cssH / 2);
94 canvas.__chart = { points: [] };
95 canvas.__redraw = null;
96 return { points: [] };
97 }
98
99 var n = data.length;
100 var maxVal = 0;
101 for (var i = 0; i < n; i++) {
102 if (data[i].total > maxVal) { maxVal = data[i].total; }
103 }
104 var axis = niceTicks(maxVal, 4);
105
106 ctx.font = FONT;
107 var tickLabels = axis.ticks.map(formatTick);
108 var tickW = 0;
109 tickLabels.forEach(function (t) {
110 var w = ctx.measureText(t).width;
111 if (w > tickW) { tickW = w; }
112 });
113 var padL = Math.ceil(tickW) + 14;
114 var padR = 20;
115 var padT = 26;
116 var padB = 26;
117 var plotW = Math.max(10, cssW - padL - padR);
118 var plotH = Math.max(10, cssH - padT - padB);
119
120 function x(i) {
121 return n === 1 ? padL + plotW / 2 : padL + (i * plotW) / (n - 1);
122 }
123 function y(v) {
124 return padT + plotH * (1 - v / axis.top);
125 }
126
127
128 axis.ticks.forEach(function (tv, ti) {
129 var yy = Math.round(y(tv)) + 0.5;
130 ctx.strokeStyle = tv === 0 ? colors.axis : colors.grid;
131 ctx.lineWidth = 1;
132 ctx.beginPath();
133 ctx.moveTo(padL, yy);
134 ctx.lineTo(cssW - padR, yy);
135 ctx.stroke();
136 ctx.fillStyle = colors.muted;
137 ctx.textAlign = 'right';
138 ctx.textBaseline = 'middle';
139 ctx.fillText(tickLabels[ti], padL - 8, yy);
140 });
141
142
143 var step = Math.max(1, Math.ceil(n / 7));
144 var firstShown = null;
145 for (var s = 0; s < n; s++) {
146 if ((n - 1 - s) % step === 0) { firstShown = s; break; }
147 }
148 ctx.textAlign = 'center';
149 ctx.textBaseline = 'alphabetic';
150 ctx.fillStyle = colors.muted;
151 for (var xi = 0; xi < n; xi++) {
152 if ((n - 1 - xi) % step !== 0) { continue; }
153 var withYear = xi === firstShown || data[xi].month.slice(5, 7) === '01';
154 ctx.fillText(monthTickLabel(data[xi].month, withYear), x(xi), cssH - 8);
155 }
156
157
158 if (hoverIndex !== null && hoverIndex >= 0 && hoverIndex < n) {
159 var hx = Math.round(x(hoverIndex)) + 0.5;
160 ctx.strokeStyle = colors.axis;
161 ctx.lineWidth = 1;
162 ctx.beginPath();
163 ctx.moveTo(hx, padT);
164 ctx.lineTo(hx, padT + plotH);
165 ctx.stroke();
166 }
167
168
169 if (n >= 2) {
170 ctx.beginPath();
171 ctx.moveTo(x(0), y(data[0].total));
172 for (var ai = 1; ai < n; ai++) {
173 ctx.lineTo(x(ai), y(data[ai].total));
174 }
175 ctx.lineTo(x(n - 1), y(0));
176 ctx.lineTo(x(0), y(0));
177 ctx.closePath();
178 ctx.globalAlpha = 0.1;
179 ctx.fillStyle = colors.line;
180 ctx.fill();
181 ctx.globalAlpha = 1;
182 }
183
184
185 if (n >= 2) {
186 ctx.beginPath();
187 ctx.moveTo(x(0), y(data[0].total));
188 for (var li = 1; li < n; li++) {
189 ctx.lineTo(x(li), y(data[li].total));
190 }
191 ctx.strokeStyle = colors.line;
192 ctx.lineWidth = 2;
193 ctx.lineJoin = 'round';
194 ctx.lineCap = 'round';
195 ctx.stroke();
196 }
197
198
199 if (n <= 24) {
200 for (var mi = 0; mi < n; mi++) {
201 var mx = x(mi);
202 var my = y(data[mi].total);
203 ctx.beginPath();
204 ctx.arc(mx, my, 6, 0, Math.PI * 2);
205 ctx.fillStyle = colors.surface;
206 ctx.fill();
207 ctx.beginPath();
208 ctx.arc(mx, my, 4, 0, Math.PI * 2);
209 ctx.fillStyle = colors.line;
210 ctx.fill();
211 }
212 }
213
214
215 var last = data[n - 1];
216 ctx.font = LABEL_FONT;
217 var labelText = formatValue(last.total);
218 var lw = ctx.measureText(labelText).width;
219 var lx = x(n - 1) + 8;
220 if (lx + lw > cssW - 4) { lx = x(n - 1) - lw - 8; }
221 var ly = y(last.total) - 12;
222 if (ly < 12) { ly = y(last.total) + 20; }
223 ctx.textAlign = 'left';
224 ctx.textBaseline = 'alphabetic';
225 ctx.fillStyle = colors.text;
226 ctx.fillText(labelText, lx, ly);
227
228 var layout = {
229 points: data.map(function (d, pi) {
230 return { x: x(pi), y: y(d.total), month: d.month, total: d.total };
231 }),
232 padL: padL,
233 padR: padR,
234 padT: padT,
235 plotH: plotH,
236 cssW: cssW,
237 cssH: cssH
238 };
239 canvas.__chart = layout;
240 canvas.__redraw = function (hi) {
241 var nextOpts = {};
242 for (var ok in opts) { nextOpts[ok] = opts[ok]; }
243 nextOpts.hoverIndex = hi;
244 drawSpendingChart(canvas, data, nextOpts);
245 };
246 return layout;
247 }
248
249
250
251 function attachHover(canvas, tooltip, formatters) {
252 if (!canvas || canvas.__hoverBound || typeof canvas.addEventListener !== 'function') { return; }
253 canvas.__hoverBound = true;
254 formatters = formatters || {};
255 var labelFor = formatters.label || function (m) { return m; };
256 var valueFor = formatters.value || function (v) { return String(v); };
257 var activeIndex = null;
258
259 function hide() {
260 if (tooltip) { tooltip.hidden = true; }
261 if (activeIndex !== null) {
262 activeIndex = null;
263 if (canvas.__redraw) { canvas.__redraw(null); }
264 }
265 }
266
267 canvas.addEventListener('pointermove', function (e) {
268 var chart = canvas.__chart;
269 if (!chart || !chart.points || !chart.points.length) { hide(); return; }
270 var rect = canvas.getBoundingClientRect();
271 var mx = e.clientX - rect.left;
272 if (mx < chart.padL - 12 || mx > chart.cssW - chart.padR + 12) { hide(); return; }
273 var best = 0;
274 var bestDist = Infinity;
275 chart.points.forEach(function (p, i) {
276 var d = Math.abs(p.x - mx);
277 if (d < bestDist) { bestDist = d; best = i; }
278 });
279 if (best !== activeIndex) {
280 activeIndex = best;
281 if (canvas.__redraw) { canvas.__redraw(best); }
282 }
283 if (!tooltip) { return; }
284 var p = chart.points[best];
285 tooltip.innerHTML = '<span class="tt-label"></span><span class="tt-value"></span>';
286 tooltip.firstChild.textContent = labelFor(p.month);
287 tooltip.lastChild.textContent = valueFor(p.total);
288 tooltip.hidden = false;
289 var ttW = tooltip.offsetWidth;
290 var ttH = tooltip.offsetHeight;
291 var left = p.x + 12;
292 if (left + ttW > chart.cssW - 4) { left = p.x - ttW - 12; }
293 var top = p.y - ttH - 10;
294 if (top < 4) { top = p.y + 14; }
295 tooltip.style.left = Math.round(left) + 'px';
296 tooltip.style.top = Math.round(top) + 'px';
297 });
298
299 canvas.addEventListener('pointerleave', hide);
300 }
301
302 return {
303 niceTicks: niceTicks,
304 monthTickLabel: monthTickLabel,
305 drawSpendingChart: drawSpendingChart,
306 attachHover: attachHover
307 };
308});
309
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.