1
2
3
4
5
6(function (exports) {
7 'use strict';
8
9
10
11
12 function setupCanvas(canvas) {
13 if (!canvas) return null;
14 const ctx = canvas.getContext('2d');
15 const dpr = window.devicePixelRatio || 1;
16 const rect = canvas.getBoundingClientRect();
17
18
19 const width = rect.width || canvas.width || 600;
20 const height = rect.height || canvas.height || 260;
21
22 canvas.width = width * dpr;
23 canvas.height = height * dpr;
24 ctx.scale(dpr, dpr);
25
26 return { ctx, width, height };
27 }
28
29
30
31
32
33
34
35 function renderSpendingChart(canvas, trendData, options = {}) {
36 if (!canvas) return;
37 const setup = setupCanvas(canvas);
38 if (!setup) return;
39
40 const { ctx, width, height } = setup;
41 const isDark = options.isDark || false;
42 const hoveredIdx = options.hoveredIndex !== undefined ? options.hoveredIndex : -1;
43
44
45 const colors = {
46 grid: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)',
47 text: isDark ? '#94a3b8' : '#64748b',
48 lineExpense: '#f43f5e',
49 lineIncome: '#10b981',
50 gradExpenseStart: isDark ? 'rgba(244, 63, 94, 0.35)' : 'rgba(244, 63, 94, 0.25)',
51 gradExpenseEnd: isDark ? 'rgba(244, 63, 94, 0.0)' : 'rgba(244, 63, 94, 0.0)',
52 gradIncomeStart: isDark ? 'rgba(16, 185, 129, 0.35)' : 'rgba(16, 185, 129, 0.25)',
53 gradIncomeEnd: isDark ? 'rgba(16, 185, 129, 0.0)' : 'rgba(16, 185, 129, 0.0)',
54 tooltipBg: isDark ? '#1e293b' : '#ffffff',
55 tooltipText: isDark ? '#f8fafc' : '#0f172a',
56 tooltipBorder: isDark ? '#334155' : '#e2e8f0'
57 };
58
59
60 ctx.clearRect(0, 0, width, height);
61
62 if (!trendData || trendData.length === 0) {
63 ctx.fillStyle = colors.text;
64 ctx.font = '500 14px sans-serif';
65 ctx.textAlign = 'center';
66 ctx.textBaseline = 'middle';
67 ctx.fillText('No transaction data available for chart', width / 2, height / 2);
68 return;
69 }
70
71
72 const paddingLeft = 55;
73 const paddingRight = 20;
74 const paddingTop = 30;
75 const paddingBottom = 40;
76
77 const plotWidth = width - paddingLeft - paddingRight;
78 const plotHeight = height - paddingTop - paddingBottom;
79
80
81 const maxVal = Math.max(
82 ...trendData.map(d => Math.max(d.expense, d.income)),
83 100
84 );
85
86
87 const yTicks = 4;
88 const tickInterval = Math.ceil(maxVal / yTicks / 50) * 50 || 50;
89 const yMax = tickInterval * yTicks;
90
91
92 ctx.font = '500 11px sans-serif';
93 ctx.textAlign = 'right';
94 ctx.textBaseline = 'middle';
95
96 for (let i = 0; i <= yTicks; i++) {
97 const val = (yMax / yTicks) * i;
98 const y = paddingTop + plotHeight - (i / yTicks) * plotHeight;
99
100
101 ctx.beginPath();
102 ctx.strokeStyle = colors.grid;
103 ctx.lineWidth = 1;
104 ctx.moveTo(paddingLeft, y);
105 ctx.lineTo(width - paddingRight, y);
106 ctx.stroke();
107
108
109 ctx.fillStyle = colors.text;
110 ctx.fillText('$' + val.toLocaleString(), paddingLeft - 8, y);
111 }
112
113
114 const points = trendData.map((d, idx) => {
115 const x = paddingLeft + (idx / Math.max(trendData.length - 1, 1)) * plotWidth;
116 const yExp = paddingTop + plotHeight - (d.expense / yMax) * plotHeight;
117 const yInc = paddingTop + plotHeight - (d.income / yMax) * plotHeight;
118 return { x, yExp, yInc, data: d };
119 });
120
121
122 function drawTrendLine(yKey, strokeColor, gradStart, gradEnd) {
123 if (points.length < 1) return;
124
125
126 const grad = ctx.createLinearGradient(0, paddingTop, 0, paddingTop + plotHeight);
127 grad.addColorStop(0, gradStart);
128 grad.addColorStop(1, gradEnd);
129
130 ctx.beginPath();
131 ctx.moveTo(points[0].x, paddingTop + plotHeight);
132 points.forEach(p => ctx.lineTo(p.x, p[yKey]));
133 ctx.lineTo(points[points.length - 1].x, paddingTop + plotHeight);
134 ctx.closePath();
135 ctx.fillStyle = grad;
136 ctx.fill();
137
138
139 ctx.beginPath();
140 ctx.strokeStyle = strokeColor;
141 ctx.lineWidth = 2.5;
142 ctx.lineJoin = 'round';
143 ctx.lineCap = 'round';
144
145 points.forEach((p, idx) => {
146 if (idx === 0) ctx.moveTo(p.x, p[yKey]);
147 else ctx.lineTo(p.x, p[yKey]);
148 });
149 ctx.stroke();
150 }
151
152
153 drawTrendLine('yInc', colors.lineIncome, colors.gradIncomeStart, colors.gradIncomeEnd);
154
155 drawTrendLine('yExp', colors.lineExpense, colors.gradExpenseStart, colors.gradExpenseEnd);
156
157
158 ctx.fillStyle = colors.text;
159 ctx.textAlign = 'center';
160 ctx.textBaseline = 'top';
161
162 const step = Math.max(1, Math.floor(trendData.length / 6));
163 points.forEach((p, idx) => {
164 if (idx % step === 0 || idx === points.length - 1) {
165 ctx.fillText(p.data.label, p.x, height - paddingBottom + 8);
166 }
167 });
168
169
170 points.forEach((p, idx) => {
171 const isHovered = idx === hoveredIdx;
172
173
174 if (p.data.expense > 0 || isHovered) {
175 ctx.beginPath();
176 ctx.arc(p.x, p.yExp, isHovered ? 6 : 3.5, 0, Math.PI * 2);
177 ctx.fillStyle = colors.lineExpense;
178 ctx.fill();
179 ctx.lineWidth = 2;
180 ctx.strokeStyle = isDark ? '#0f172a' : '#ffffff';
181 ctx.stroke();
182 }
183
184
185 if (p.data.income > 0 || isHovered) {
186 ctx.beginPath();
187 ctx.arc(p.x, p.yInc, isHovered ? 6 : 3.5, 0, Math.PI * 2);
188 ctx.fillStyle = colors.lineIncome;
189 ctx.fill();
190 ctx.lineWidth = 2;
191 ctx.strokeStyle = isDark ? '#0f172a' : '#ffffff';
192 ctx.stroke();
193 }
194
195
196 if (isHovered) {
197 ctx.beginPath();
198 ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)';
199 ctx.setLineDash([4, 4]);
200 ctx.moveTo(p.x, paddingTop);
201 ctx.lineTo(p.x, paddingTop + plotHeight);
202 ctx.stroke();
203 ctx.setLineDash([]);
204 }
205 });
206
207
208 if (hoveredIdx >= 0 && hoveredIdx < points.length) {
209 const hp = points[hoveredIdx];
210 const d = hp.data;
211
212 const title = d.label + ' (' + d.date + ')';
213 const incText = 'Income: $' + d.income.toFixed(2);
214 const expText = 'Expense: $' + d.expense.toFixed(2);
215
216 ctx.font = '600 12px sans-serif';
217 const w1 = ctx.measureText(title).width;
218 ctx.font = '500 11px sans-serif';
219 const w2 = ctx.measureText(incText).width;
220 const w3 = ctx.measureText(expText).width;
221
222 const tooltipW = Math.max(w1, w2, w3) + 24;
223 const tooltipH = 68;
224
225 let ttX = hp.x + 12;
226 if (ttX + tooltipW > width - 10) ttX = hp.x - tooltipW - 12;
227 let ttY = Math.min(hp.yExp, hp.yInc) - 40;
228 if (ttY < 10) ttY = 10;
229 if (ttY + tooltipH > height - 10) ttY = height - tooltipH - 10;
230
231
232 ctx.fillStyle = colors.tooltipBg;
233 ctx.shadowColor = 'rgba(0,0,0,0.15)';
234 ctx.shadowBlur = 10;
235 ctx.shadowOffsetY = 4;
236 ctx.beginPath();
237 ctx.roundRect ? ctx.roundRect(ttX, ttY, tooltipW, tooltipH, 8) : ctx.rect(ttX, ttY, tooltipW, tooltipH);
238 ctx.fill();
239 ctx.shadowColor = 'transparent';
240
241 ctx.strokeStyle = colors.tooltipBorder;
242 ctx.lineWidth = 1;
243 ctx.stroke();
244
245
246 ctx.textAlign = 'left';
247 ctx.textBaseline = 'top';
248
249
250 ctx.fillStyle = colors.tooltipText;
251 ctx.font = '600 12px sans-serif';
252 ctx.fillText(title, ttX + 12, ttY + 8);
253
254
255 ctx.font = '500 11px sans-serif';
256 ctx.fillStyle = colors.lineIncome;
257 ctx.fillText(incText, ttX + 12, ttY + 28);
258
259
260 ctx.fillStyle = colors.lineExpense;
261 ctx.fillText(expText, ttX + 12, ttY + 46);
262 }
263 }
264
265
266
267
268
269
270
271 function renderCategoryDonutChart(canvas, categoryData, options = {}) {
272 if (!canvas) return;
273 const setup = setupCanvas(canvas);
274 if (!setup) return;
275
276 const { ctx, width, height } = setup;
277 const isDark = options.isDark || false;
278 const hoveredIdx = options.hoveredIndex !== undefined ? options.hoveredIndex : -1;
279
280 ctx.clearRect(0, 0, width, height);
281
282
283 const activeCats = (categoryData || []).filter(c => c.spent > 0);
284 const totalSpent = activeCats.reduce((acc, c) => acc + c.spent, 0);
285
286 if (activeCats.length === 0 || totalSpent === 0) {
287 ctx.fillStyle = isDark ? '#94a3b8' : '#64748b';
288 ctx.font = '500 14px sans-serif';
289 ctx.textAlign = 'center';
290 ctx.textBaseline = 'middle';
291 ctx.fillText('No expense data available for chart', width / 2, height / 2);
292 return;
293 }
294
295 const centerX = width / 2;
296 const centerY = height / 2;
297 const outerRadius = Math.min(width, height) / 2 - 20;
298 const innerRadius = outerRadius * 0.62;
299
300 let startAngle = -Math.PI / 2;
301
302 activeCats.forEach((cat, idx) => {
303 const sliceAngle = (cat.spent / totalSpent) * (Math.PI * 2);
304 const endAngle = startAngle + sliceAngle;
305 const isHovered = idx === hoveredIdx;
306
307 ctx.beginPath();
308 ctx.arc(centerX, centerY, isHovered ? outerRadius + 6 : outerRadius, startAngle, endAngle);
309 ctx.arc(centerX, centerY, innerRadius, endAngle, startAngle, true);
310 ctx.closePath();
311
312 ctx.fillStyle = cat.color || '#6366f1';
313 ctx.fill();
314
315 ctx.lineWidth = 2;
316 ctx.strokeStyle = isDark ? '#0f172a' : '#ffffff';
317 ctx.stroke();
318
319 startAngle = endAngle;
320 });
321
322
323 ctx.fillStyle = isDark ? '#f8fafc' : '#0f172a';
324 ctx.font = 'bold 16px sans-serif';
325 ctx.textAlign = 'center';
326 ctx.textBaseline = 'middle';
327 ctx.fillText('$' + totalSpent.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }), centerX, centerY - 6);
328
329 ctx.fillStyle = isDark ? '#94a3b8' : '#64748b';
330 ctx.font = '500 11px sans-serif';
331 ctx.fillText('Total Expenses', centerX, centerY + 14);
332 }
333
334 exports.renderSpendingChart = renderSpendingChart;
335 exports.renderCategoryDonutChart = renderCategoryDonutChart;
336
337})(typeof exports !== 'undefined' ? exports : (window.FinanceCharts = {}));
338
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.