1
2
3
4
5(function (root, factory) {
6 if (typeof exports === 'object' && typeof module !== 'undefined') {
7 module.exports = factory();
8 } else if (typeof define === 'function' && define.amd) {
9 define(factory);
10 } else {
11 root.FinanceCharts = factory();
12 }
13})(typeof self !== 'undefined' ? self : this, function () {
14 'use strict';
15
16 class SpendingChart {
17 constructor(canvasElement, options = {}) {
18 this.canvas = canvasElement;
19 this.ctx = canvasElement ? canvasElement.getContext('2d') : null;
20 this.options = Object.assign({
21 isDark: true,
22 showIncome: true,
23 showExpense: true,
24 padding: { top: 30, right: 30, bottom: 40, left: 60 },
25 incomeColor: '#10b981',
26 expenseColor: '#f43f5e',
27 accentColor: '#6366f1',
28 gridColorDark: '#334155',
29 gridColorLight: '#e2e8f0',
30 textColorDark: '#94a3b8',
31 textColorLight: '#64748b'
32 }, options);
33
34 this.data = { labels: [], incomeValues: [], expenseValues: [] };
35 this.hoverIndex = -1;
36 this._mousePos = null;
37
38 if (this.canvas) {
39 this.initListeners();
40 }
41 }
42
43 initListeners() {
44 this.handleMouseMove = this.handleMouseMove.bind(this);
45 this.handleMouseLeave = this.handleMouseLeave.bind(this);
46
47 this.canvas.addEventListener('mousemove', this.handleMouseMove);
48 this.canvas.addEventListener('mouseleave', this.handleMouseLeave);
49 }
50
51 destroy() {
52 if (this.canvas) {
53 this.canvas.removeEventListener('mousemove', this.handleMouseMove);
54 this.canvas.removeEventListener('mouseleave', this.handleMouseLeave);
55 }
56 }
57
58 handleMouseMove(e) {
59 const rect = this.canvas.getBoundingClientRect();
60 const x = e.clientX - rect.left;
61 const y = e.clientY - rect.top;
62 this._mousePos = { x, y };
63
64 if (!this.data.labels || this.data.labels.length === 0) return;
65
66 const { padding } = this.options;
67 const displayWidth = this.canvas.width / (window.devicePixelRatio || 1);
68 const graphWidth = displayWidth - padding.left - padding.right;
69
70 const step = graphWidth / Math.max(1, this.data.labels.length - 1);
71 let closestIdx = Math.round((x - padding.left) / step);
72 closestIdx = Math.max(0, Math.min(this.data.labels.length - 1, closestIdx));
73
74 if (this.hoverIndex !== closestIdx) {
75 this.hoverIndex = closestIdx;
76 this.render();
77 }
78 }
79
80 handleMouseLeave() {
81 this._mousePos = null;
82 if (this.hoverIndex !== -1) {
83 this.hoverIndex = -1;
84 this.render();
85 }
86 }
87
88 setData(chartData) {
89 this.data = chartData || { labels: [], incomeValues: [], expenseValues: [] };
90 this.render();
91 }
92
93 setTheme(isDark) {
94 this.options.isDark = isDark;
95 this.render();
96 }
97
98 setupCanvasScaling() {
99 if (!this.canvas || !this.ctx) return;
100 const dpr = window.devicePixelRatio || 1;
101 const rect = this.canvas.parentElement ? this.canvas.parentElement.getBoundingClientRect() : this.canvas.getBoundingClientRect();
102 const width = rect.width || 600;
103 const height = rect.height || 320;
104
105 this.canvas.width = width * dpr;
106 this.canvas.height = height * dpr;
107 this.canvas.style.width = width + 'px';
108 this.canvas.style.height = height + 'px';
109
110 this.ctx.resetTransform ? this.ctx.resetTransform() : this.ctx.setTransform(1, 0, 0, 1, 0, 0);
111 this.ctx.scale(dpr, dpr);
112
113 return { width, height };
114 }
115
116 render() {
117 if (!this.canvas || !this.ctx) return;
118 const dims = this.setupCanvasScaling();
119 if (!dims) return;
120
121 const { width, height } = dims;
122 const { padding, isDark, incomeColor, expenseColor, gridColorDark, gridColorLight, textColorDark, textColorLight } = this.options;
123 const ctx = this.ctx;
124
125
126 ctx.clearRect(0, 0, width, height);
127
128 const labels = this.data.labels || [];
129 const incVals = this.data.incomeValues || [];
130 const expVals = this.data.expenseValues || [];
131
132 if (labels.length === 0) {
133 ctx.fillStyle = isDark ? textColorDark : textColorLight;
134 ctx.font = '14px sans-serif';
135 ctx.textAlign = 'center';
136 ctx.fillText('No transaction data for chart visualization', width / 2, height / 2);
137 return;
138 }
139
140
141 let maxVal = 0;
142 labels.forEach((_, i) => {
143 if (this.options.showIncome) maxVal = Math.max(maxVal, incVals[i] || 0);
144 if (this.options.showExpense) maxVal = Math.max(maxVal, expVals[i] || 0);
145 });
146 if (maxVal === 0) maxVal = 100;
147 maxVal = Math.ceil(maxVal * 1.15);
148
149 const graphWidth = width - padding.left - padding.right;
150 const graphHeight = height - padding.top - padding.bottom;
151
152
153 const gridColor = isDark ? gridColorDark : gridColorLight;
154 const textColor = isDark ? textColorDark : textColorLight;
155 const yTicks = 4;
156
157 ctx.strokeStyle = gridColor;
158 ctx.lineWidth = 1;
159 ctx.fillStyle = textColor;
160 ctx.font = '11px sans-serif';
161 ctx.textAlign = 'right';
162 ctx.textBaseline = 'middle';
163
164 for (let i = 0; i <= yTicks; i++) {
165 const val = (maxVal / yTicks) * i;
166 const y = height - padding.bottom - (i / yTicks) * graphHeight;
167
168 ctx.beginPath();
169 ctx.moveTo(padding.left, y);
170 ctx.lineTo(width - padding.right, y);
171 ctx.stroke();
172
173 ctx.fillText('$' + Math.round(val).toLocaleString(), padding.left - 8, y);
174 }
175
176
177 ctx.textAlign = 'center';
178 ctx.textBaseline = 'top';
179 const labelStep = Math.max(1, Math.ceil(labels.length / 8));
180
181 labels.forEach((lbl, i) => {
182 if (i % labelStep === 0 || i === labels.length - 1) {
183 const x = padding.left + (i / Math.max(1, labels.length - 1)) * graphWidth;
184
185 const displayLabel = lbl.length > 5 ? lbl.substring(5) : lbl;
186 ctx.fillText(displayLabel, x, height - padding.bottom + 10);
187 }
188 });
189
190
191 const drawLineSeries = (values, color, strokeWidth = 2.5) => {
192 if (values.length === 0) return;
193
194 const points = values.map((val, i) => {
195 const x = padding.left + (i / Math.max(1, labels.length - 1)) * graphWidth;
196 const y = height - padding.bottom - (val / maxVal) * graphHeight;
197 return { x, y, val };
198 });
199
200
201 const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
202 gradient.addColorStop(0, color + '33');
203 gradient.addColorStop(1, color + '00');
204
205 ctx.beginPath();
206 ctx.moveTo(points[0].x, height - padding.bottom);
207 points.forEach(p => ctx.lineTo(p.x, p.y));
208 ctx.lineTo(points[points.length - 1].x, height - padding.bottom);
209 ctx.closePath();
210 ctx.fillStyle = gradient;
211 ctx.fill();
212
213
214 ctx.beginPath();
215 ctx.moveTo(points[0].x, points[0].y);
216 for (let i = 1; i < points.length; i++) {
217 ctx.lineTo(points[i].x, points[i].y);
218 }
219 ctx.strokeStyle = color;
220 ctx.lineWidth = strokeWidth;
221 ctx.stroke();
222
223
224 points.forEach((p, idx) => {
225 ctx.beginPath();
226 ctx.arc(p.x, p.y, idx === this.hoverIndex ? 6 : 3.5, 0, Math.PI * 2);
227 ctx.fillStyle = isDark ? '#0f172a' : '#ffffff';
228 ctx.fill();
229 ctx.strokeStyle = color;
230 ctx.lineWidth = 2;
231 ctx.stroke();
232 });
233
234 return points;
235 };
236
237 let incPoints = null;
238 let expPoints = null;
239
240 if (this.options.showIncome) {
241 incPoints = drawLineSeries(incVals, incomeColor);
242 }
243
244 if (this.options.showExpense) {
245 expPoints = drawLineSeries(expVals, expenseColor);
246 }
247
248
249 if (this.hoverIndex >= 0 && this.hoverIndex < labels.length) {
250 const idx = this.hoverIndex;
251 const x = padding.left + (idx / Math.max(1, labels.length - 1)) * graphWidth;
252
253
254 ctx.beginPath();
255 ctx.setLineDash([4, 4]);
256 ctx.moveTo(x, padding.top);
257 ctx.lineTo(x, height - padding.bottom);
258 ctx.strokeStyle = isDark ? '#64748b' : '#94a3b8';
259 ctx.lineWidth = 1;
260 ctx.stroke();
261 ctx.setLineDash([]);
262
263
264 const dateStr = labels[idx];
265 const incVal = incVals[idx] || 0;
266 const expVal = expVals[idx] || 0;
267
268 const ttLines = [
269 `Date: ${dateStr}`,
270 this.options.showIncome ? `Income: +$${incVal.toFixed(2)}` : null,
271 this.options.showExpense ? `Expense: -$${expVal.toFixed(2)}` : null
272 ].filter(Boolean);
273
274 ctx.font = '12px sans-serif';
275 const ttWidth = 140;
276 const ttHeight = ttLines.length * 18 + 12;
277
278 let ttX = x + 12;
279 if (ttX + ttWidth > width - padding.right) {
280 ttX = x - ttWidth - 12;
281 }
282 const ttY = padding.top + 10;
283
284
285 ctx.fillStyle = isDark ? 'rgba(15, 23, 42, 0.92)' : 'rgba(255, 255, 255, 0.95)';
286 ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
287 ctx.shadowBlur = 10;
288 ctx.shadowOffsetX = 0;
289 ctx.shadowOffsetY = 4;
290
291 ctx.beginPath();
292 ctx.roundRect ? ctx.roundRect(ttX, ttY, ttWidth, ttHeight, 6) : ctx.rect(ttX, ttY, ttWidth, ttHeight);
293 ctx.fill();
294
295 ctx.shadowColor = 'transparent';
296 ctx.strokeStyle = isDark ? '#334155' : '#cbd5e1';
297 ctx.lineWidth = 1;
298 ctx.stroke();
299
300
301 ctx.textAlign = 'left';
302 ctx.textBaseline = 'top';
303
304 ttLines.forEach((line, i) => {
305 if (line.startsWith('Income:')) {
306 ctx.fillStyle = incomeColor;
307 } else if (line.startsWith('Expense:')) {
308 ctx.fillStyle = expenseColor;
309 } else {
310 ctx.fillStyle = isDark ? '#f8fafc' : '#0f172a';
311 }
312 ctx.fillText(line, ttX + 10, ttY + 8 + i * 18);
313 });
314 }
315 }
316 }
317
318
319 class CategoryDonutChart {
320 constructor(canvasElement, options = {}) {
321 this.canvas = canvasElement;
322 this.ctx = canvasElement ? canvasElement.getContext('2d') : null;
323 this.options = Object.assign({
324 isDark: true,
325 colors: [
326 '#6366f1', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6',
327 '#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#64748b'
328 ]
329 }, options);
330 this.data = {};
331 }
332
333 setData(categoryData) {
334 this.data = categoryData || {};
335 this.render();
336 }
337
338 setTheme(isDark) {
339 this.options.isDark = isDark;
340 this.render();
341 }
342
343 render() {
344 if (!this.canvas || !this.ctx) return;
345 const dpr = window.devicePixelRatio || 1;
346 const rect = this.canvas.parentElement ? this.canvas.parentElement.getBoundingClientRect() : this.canvas.getBoundingClientRect();
347 const width = rect.width || 300;
348 const height = rect.height || 260;
349
350 this.canvas.width = width * dpr;
351 this.canvas.height = height * dpr;
352 this.canvas.style.width = width + 'px';
353 this.canvas.style.height = height + 'px';
354
355 const ctx = this.ctx;
356 ctx.resetTransform ? ctx.resetTransform() : ctx.setTransform(1, 0, 0, 1, 0, 0);
357 ctx.scale(dpr, dpr);
358
359 ctx.clearRect(0, 0, width, height);
360
361 const entries = Object.entries(this.data).filter(([_, val]) => val > 0);
362 const total = entries.reduce((sum, [_, val]) => sum + val, 0);
363
364 if (total === 0) {
365 ctx.fillStyle = this.options.isDark ? '#94a3b8' : '#64748b';
366 ctx.font = '13px sans-serif';
367 ctx.textAlign = 'center';
368 ctx.fillText('No expenses to display', width / 2, height / 2);
369 return;
370 }
371
372 const centerX = width / 2;
373 const centerY = height / 2;
374 const radius = Math.min(width, height) / 2 - 20;
375 const innerRadius = radius * 0.62;
376
377 let startAngle = -Math.PI / 2;
378
379 entries.forEach(([cat, val], idx) => {
380 const sliceAngle = (val / total) * Math.PI * 2;
381 const color = this.options.colors[idx % this.options.colors.length];
382
383 ctx.beginPath();
384 ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
385 ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true);
386 ctx.closePath();
387
388 ctx.fillStyle = color;
389 ctx.fill();
390
391 startAngle += sliceAngle;
392 });
393
394
395 ctx.fillStyle = this.options.isDark ? '#f8fafc' : '#0f172a';
396 ctx.font = 'bold 16px sans-serif';
397 ctx.textAlign = 'center';
398 ctx.textBaseline = 'middle';
399 ctx.fillText('$' + Math.round(total).toLocaleString(), centerX, centerY - 6);
400
401 ctx.fillStyle = this.options.isDark ? '#94a3b8' : '#64748b';
402 ctx.font = '11px sans-serif';
403 ctx.fillText('Total Spent', centerX, centerY + 14);
404 }
405 }
406
407 return {
408 SpendingChart,
409 CategoryDonutChart
410 };
411});
412
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.