@cognima/banners 0.0.1-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/assets/fonts/Manrope/Manrope-Bold.ttf +0 -0
  2. package/assets/fonts/Manrope/Manrope-Regular.ttf +0 -0
  3. package/assets/fonts/Others/AbyssinicaSIL-Regular.ttf +0 -0
  4. package/assets/fonts/Others/ChirpRegular.ttf +0 -0
  5. package/assets/fonts/Poppins/Poppins-Bold.ttf +0 -0
  6. package/assets/fonts/Poppins/Poppins-Medium.ttf +0 -0
  7. package/assets/fonts/Poppins/Poppins-Regular.ttf +0 -0
  8. package/assets/placeholders/album_art.png +0 -0
  9. package/assets/placeholders/avatar.png +0 -0
  10. package/assets/placeholders/badge.jpg +0 -0
  11. package/assets/placeholders/badge.png +0 -0
  12. package/assets/placeholders/badge_2.jpg +0 -0
  13. package/assets/placeholders/badge_3.jpg +0 -0
  14. package/assets/placeholders/badge_4.jpg +0 -0
  15. package/assets/placeholders/badge_5.jpg +0 -0
  16. package/assets/placeholders/banner.jpeg +0 -0
  17. package/assets/placeholders/images.jpeg +0 -0
  18. package/index.js +153 -0
  19. package/package.json +34 -0
  20. package/src/animation-effects.js +631 -0
  21. package/src/cache-manager.js +258 -0
  22. package/src/community-banner.js +1536 -0
  23. package/src/constants.js +208 -0
  24. package/src/discord-profile.js +584 -0
  25. package/src/e-commerce-banner.js +1214 -0
  26. package/src/effects.js +355 -0
  27. package/src/error-handler.js +305 -0
  28. package/src/event-banner.js +1319 -0
  29. package/src/facebook-post.js +679 -0
  30. package/src/gradient-welcome.js +430 -0
  31. package/src/image-filters.js +1034 -0
  32. package/src/image-processor.js +1014 -0
  33. package/src/instagram-post.js +504 -0
  34. package/src/interactive-elements.js +1208 -0
  35. package/src/linkedin-post.js +658 -0
  36. package/src/marketing-banner.js +1089 -0
  37. package/src/minimalist-banner.js +892 -0
  38. package/src/modern-profile.js +755 -0
  39. package/src/performance-optimizer.js +216 -0
  40. package/src/telegram-header.js +544 -0
  41. package/src/test-runner.js +645 -0
  42. package/src/tiktok-post.js +713 -0
  43. package/src/twitter-header.js +604 -0
  44. package/src/validator.js +442 -0
  45. package/src/welcome-leave.js +445 -0
  46. package/src/whatsapp-status.js +386 -0
  47. package/src/youtube-thumbnail.js +681 -0
  48. package/utils.js +710 -0
@@ -0,0 +1,544 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Módulo de Banner de Cabeçalho do Telegram
5
+ *
6
+ * Este módulo gera banners no estilo de cabeçalho de canal do Telegram com
7
+ * imagem de capa, avatar, título, descrição e contadores.
8
+ *
9
+ * @author Cognima Team (melhorado)
10
+ * @version 2.0.0
11
+ */
12
+
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+
15
+ const pureimage = require("pureimage");
16
+ const path = require("path");
17
+ const {
18
+ loadImageWithAxios,
19
+ encodeToBuffer,
20
+ roundRect,
21
+ wrapText,
22
+ registerFontIfNeeded,
23
+ isValidHexColor,
24
+ DEFAULT_FONT_FAMILY,
25
+ applyTextShadow,
26
+ clearShadow,
27
+ createLinearGradient,
28
+ hexToRgba
29
+ } = require("../utils");
30
+
31
+ const {
32
+ DEFAULT_COLORS,
33
+ LAYOUT,
34
+ DEFAULT_DIMENSIONS
35
+ } = require("./constants");
36
+
37
+ const {
38
+ applyGlassmorphism
39
+ } = require("./effects");
40
+
41
+ /**
42
+ * @class TelegramHeader
43
+ * @classdesc Gera um banner no estilo de cabeçalho de canal do Telegram.
44
+ * @example const header = new TelegramHeader()
45
+ * .setTitle("Canal de Notícias")
46
+ * .setDescription("Atualizações diárias sobre tecnologia e ciência")
47
+ * .setAvatar("avatar.png")
48
+ * .setCoverImage("cover.jpg")
49
+ * .setSubscribers(15000)
50
+ * .build();
51
+ */
52
+ module.exports = class TelegramHeader {
53
+ constructor(options) {
54
+ // Dados Principais
55
+ this.title = "Nome do Canal";
56
+ this.description = null;
57
+ this.avatar = null;
58
+ this.coverImage = null;
59
+ this.subscribers = 0;
60
+ this.posts = 0;
61
+ this.isVerified = false;
62
+ this.isPublic = true;
63
+ this.link = null;
64
+
65
+ // Personalização Visual
66
+ this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path };
67
+ this.primaryColor = DEFAULT_COLORS.telegram.primary;
68
+ this.backgroundColor = DEFAULT_COLORS.telegram.light;
69
+ this.textColor = DEFAULT_COLORS.text.dark;
70
+ this.secondaryTextColor = DEFAULT_COLORS.text.muted;
71
+ this.useGlassmorphism = false;
72
+ this.useTextShadow = false;
73
+
74
+ // Configurações de Layout
75
+ this.cardWidth = DEFAULT_DIMENSIONS.banner.width;
76
+ this.cardHeight = 300;
77
+ this.cornerRadius = LAYOUT.cornerRadius.medium;
78
+ this.avatarSize = 100;
79
+ }
80
+
81
+ // --- Setters para Dados Principais ---
82
+ /**
83
+ * Define o título do canal
84
+ * @param {string} text - Título do canal
85
+ * @returns {TelegramHeader} - Instância atual para encadeamento
86
+ */
87
+ setTitle(text) {
88
+ if (!text || typeof text !== "string") throw new Error("O título do canal deve ser uma string não vazia.");
89
+ this.title = text;
90
+ return this;
91
+ }
92
+
93
+ /**
94
+ * Define a descrição do canal
95
+ * @param {string} text - Descrição do canal
96
+ * @returns {TelegramHeader} - Instância atual para encadeamento
97
+ */
98
+ setDescription(text) {
99
+ if (!text || typeof text !== "string") throw new Error("A descrição do canal deve ser uma string não vazia.");
100
+ this.description = text;
101
+ return this;
102
+ }
103
+
104
+ /**
105
+ * Define o avatar do canal
106
+ * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar
107
+ * @returns {TelegramHeader} - Instância atual para encadeamento
108
+ */
109
+ setAvatar(image) {
110
+ if (!image) throw new Error("A fonte da imagem do avatar não pode estar vazia.");
111
+ this.avatar = image;
112
+ return this;
113
+ }
114
+
115
+ /**
116
+ * Define a imagem de capa do canal
117
+ * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem de capa
118
+ * @returns {TelegramHeader} - Instância atual para encadeamento
119
+ */
120
+ setCoverImage(image) {
121
+ if (!image) throw new Error("A fonte da imagem de capa não pode estar vazia.");
122
+ this.coverImage = image;
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * Define o número de inscritos
128
+ * @param {number} count - Número de inscritos
129
+ * @returns {TelegramHeader} - Instância atual para encadeamento
130
+ */
131
+ setSubscribers(count) {
132
+ if (typeof count !== "number" || count < 0) throw new Error("O número de inscritos deve ser um número não negativo.");
133
+ this.subscribers = count;
134
+ return this;
135
+ }
136
+
137
+ /**
138
+ * Define o número de posts
139
+ * @param {number} count - Número de posts
140
+ * @returns {TelegramHeader} - Instância atual para encadeamento
141
+ */
142
+ setPosts(count) {
143
+ if (typeof count !== "number" || count < 0) throw new Error("O número de posts deve ser um número não negativo.");
144
+ this.posts = count;
145
+ return this;
146
+ }
147
+
148
+ /**
149
+ * Define se o canal é verificado
150
+ * @param {boolean} isVerified - Se o canal é verificado
151
+ * @returns {TelegramHeader} - Instância atual para encadeamento
152
+ */
153
+ setVerified(isVerified = true) {
154
+ this.isVerified = !!isVerified;
155
+ return this;
156
+ }
157
+
158
+ /**
159
+ * Define se o canal é público
160
+ * @param {boolean} isPublic - Se o canal é público
161
+ * @returns {TelegramHeader} - Instância atual para encadeamento
162
+ */
163
+ setPublic(isPublic = true) {
164
+ this.isPublic = !!isPublic;
165
+ return this;
166
+ }
167
+
168
+ /**
169
+ * Define o link do canal
170
+ * @param {string} link - Link do canal (ex: "t.me/canalexemplo")
171
+ * @returns {TelegramHeader} - Instância atual para encadeamento
172
+ */
173
+ setLink(link) {
174
+ if (!link || typeof link !== "string") throw new Error("O link do canal deve ser uma string não vazia.");
175
+ this.link = link;
176
+ return this;
177
+ }
178
+
179
+ // --- Setters para Personalização Visual ---
180
+ /**
181
+ * Define a cor primária
182
+ * @param {string} color - Cor hexadecimal
183
+ * @returns {TelegramHeader} - Instância atual para encadeamento
184
+ */
185
+ setPrimaryColor(color) {
186
+ if (!color || !isValidHexColor(color)) throw new Error("Cor primária inválida. Use o formato hexadecimal.");
187
+ this.primaryColor = color;
188
+ return this;
189
+ }
190
+
191
+ /**
192
+ * Define a cor de fundo
193
+ * @param {string} color - Cor hexadecimal
194
+ * @returns {TelegramHeader} - Instância atual para encadeamento
195
+ */
196
+ setBackgroundColor(color) {
197
+ if (!color || !isValidHexColor(color)) throw new Error("Cor de fundo inválida. Use o formato hexadecimal.");
198
+ this.backgroundColor = color;
199
+ return this;
200
+ }
201
+
202
+ /**
203
+ * Define a cor do texto principal
204
+ * @param {string} color - Cor hexadecimal
205
+ * @returns {TelegramHeader} - Instância atual para encadeamento
206
+ */
207
+ setTextColor(color) {
208
+ if (!color || !isValidHexColor(color)) throw new Error("Cor de texto inválida. Use o formato hexadecimal.");
209
+ this.textColor = color;
210
+ return this;
211
+ }
212
+
213
+ /**
214
+ * Define a cor do texto secundário
215
+ * @param {string} color - Cor hexadecimal
216
+ * @returns {TelegramHeader} - Instância atual para encadeamento
217
+ */
218
+ setSecondaryTextColor(color) {
219
+ if (!color || !isValidHexColor(color)) throw new Error("Cor de texto secundário inválida. Use o formato hexadecimal.");
220
+ this.secondaryTextColor = color;
221
+ return this;
222
+ }
223
+
224
+ /**
225
+ * Ativa ou desativa o efeito de glassmorphism
226
+ * @param {boolean} enabled - Se o efeito deve ser ativado
227
+ * @returns {TelegramHeader} - Instância atual para encadeamento
228
+ */
229
+ enableGlassmorphism(enabled = true) {
230
+ this.useGlassmorphism = enabled;
231
+ return this;
232
+ }
233
+
234
+ /**
235
+ * Ativa ou desativa a sombra de texto
236
+ * @param {boolean} enabled - Se a sombra de texto deve ser ativada
237
+ * @returns {TelegramHeader} - Instância atual para encadeamento
238
+ */
239
+ enableTextShadow(enabled = true) {
240
+ this.useTextShadow = enabled;
241
+ return this;
242
+ }
243
+
244
+ /**
245
+ * Define as dimensões do card
246
+ * @param {number} width - Largura do card em pixels
247
+ * @param {number} height - Altura do card em pixels
248
+ * @returns {TelegramHeader} - Instância atual para encadeamento
249
+ */
250
+ setCardDimensions(width, height) {
251
+ if (typeof width !== "number" || width < 600 || width > 1920) {
252
+ throw new Error("A largura do card deve estar entre 600 e 1920 pixels.");
253
+ }
254
+
255
+ if (typeof height !== "number" || height < 200 || height > 600) {
256
+ throw new Error("A altura do card deve estar entre 200 e 600 pixels.");
257
+ }
258
+
259
+ this.cardWidth = width;
260
+ this.cardHeight = height;
261
+
262
+ return this;
263
+ }
264
+
265
+ /**
266
+ * Define o raio dos cantos arredondados
267
+ * @param {number} radius - Raio dos cantos em pixels
268
+ * @returns {TelegramHeader} - Instância atual para encadeamento
269
+ */
270
+ setCornerRadius(radius) {
271
+ if (typeof radius !== "number" || radius < 0) throw new Error("O raio dos cantos deve ser um número não negativo.");
272
+ this.cornerRadius = radius;
273
+ return this;
274
+ }
275
+
276
+ /**
277
+ * Define o tamanho do avatar
278
+ * @param {number} size - Tamanho do avatar em pixels
279
+ * @returns {TelegramHeader} - Instância atual para encadeamento
280
+ */
281
+ setAvatarSize(size) {
282
+ if (typeof size !== "number" || size < 60 || size > 200) {
283
+ throw new Error("O tamanho do avatar deve estar entre 60 e 200 pixels.");
284
+ }
285
+
286
+ this.avatarSize = size;
287
+ return this;
288
+ }
289
+
290
+ // --- Método de Construção ---
291
+ /**
292
+ * Constrói o banner e retorna um buffer de imagem
293
+ * @returns {Promise<Buffer>} - Buffer contendo a imagem do banner
294
+ */
295
+ async build() {
296
+ // --- Registro de Fonte ---
297
+ const registeredFontName = await registerFontIfNeeded(this.font);
298
+
299
+ // --- Configuração do Canvas ---
300
+ const cardWidth = this.cardWidth;
301
+ const cardHeight = this.cardHeight;
302
+ const coverHeight = cardHeight * 0.6;
303
+ const avatarSize = this.avatarSize;
304
+ const padding = 20;
305
+ const cornerRadius = this.cornerRadius;
306
+
307
+ const canvas = pureimage.make(cardWidth, cardHeight);
308
+ const ctx = canvas.getContext("2d");
309
+
310
+ // --- Desenha Plano de Fundo ---
311
+ ctx.fillStyle = this.backgroundColor;
312
+ ctx.fillRect(0, 0, cardWidth, cardHeight);
313
+
314
+ // --- Desenha Imagem de Capa ---
315
+ if (this.coverImage) {
316
+ try {
317
+ ctx.save();
318
+
319
+ // Define o caminho de recorte para a capa (apenas cantos superiores arredondados)
320
+ ctx.beginPath();
321
+ ctx.moveTo(0, coverHeight); // Inicia no canto inferior esquerdo
322
+ ctx.lineTo(0, cornerRadius); // Borda esquerda até o raio
323
+ ctx.quadraticCurveTo(0, 0, cornerRadius, 0); // Canto superior esquerdo
324
+ ctx.lineTo(cardWidth - cornerRadius, 0); // Borda superior
325
+ ctx.quadraticCurveTo(cardWidth, 0, cardWidth, cornerRadius); // Canto superior direito
326
+ ctx.lineTo(cardWidth, coverHeight); // Borda direita para baixo
327
+ ctx.closePath(); // Fecha o caminho de volta para o canto inferior direito (implicitamente)
328
+ ctx.clip();
329
+
330
+ const img = await loadImageWithAxios(this.coverImage);
331
+ const aspect = img.width / img.height;
332
+ let drawWidth = cardWidth;
333
+ let drawHeight = cardWidth / aspect;
334
+
335
+ // Ajusta as dimensões para cobrir toda a área da capa
336
+ if (drawHeight < coverHeight) {
337
+ drawHeight = coverHeight;
338
+ drawWidth = coverHeight * aspect;
339
+ }
340
+
341
+ const offsetX = (cardWidth - drawWidth) / 2;
342
+ const offsetY = 0;
343
+
344
+ ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
345
+
346
+ // Aplica sobreposição para melhorar legibilidade
347
+ ctx.fillStyle = hexToRgba("#000000", 0.3);
348
+ ctx.fillRect(0, 0, cardWidth, coverHeight);
349
+
350
+ ctx.restore();
351
+ } catch (e) {
352
+ console.error("Falha ao desenhar imagem de capa:", e.message);
353
+ }
354
+ }
355
+
356
+ // --- Desenha Avatar ---
357
+ const avatarX = padding;
358
+ const avatarY = coverHeight - avatarSize / 2;
359
+
360
+ ctx.save();
361
+ ctx.beginPath();
362
+ ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
363
+ ctx.closePath();
364
+ ctx.clip();
365
+
366
+ try {
367
+ const avatarImg = await loadImageWithAxios(this.avatar || path.join(__dirname, "../assets/placeholders/avatar.png"));
368
+ ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize);
369
+ } catch (e) {
370
+ console.error("Falha ao desenhar avatar:", e.message);
371
+
372
+ // Avatar de fallback
373
+ ctx.fillStyle = this.primaryColor;
374
+ ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize);
375
+
376
+ ctx.fillStyle = "#FFFFFF";
377
+ ctx.font = `bold ${avatarSize / 3}px ${registeredFontName}-Bold`;
378
+ ctx.textAlign = "center";
379
+ ctx.textBaseline = "middle";
380
+ ctx.fillText(this.title.charAt(0).toUpperCase(), avatarX + avatarSize / 2, avatarY + avatarSize / 2);
381
+ }
382
+
383
+ ctx.restore();
384
+
385
+ // Borda do avatar
386
+ ctx.strokeStyle = this.backgroundColor;
387
+ ctx.lineWidth = 4;
388
+ ctx.beginPath();
389
+ ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + ctx.lineWidth / 2, 0, Math.PI * 2);
390
+ ctx.stroke();
391
+ ctx.closePath();
392
+
393
+ // --- Desenha Área de Informações ---
394
+ const infoX = avatarX + avatarSize + padding;
395
+ const infoY = coverHeight + padding;
396
+ const infoWidth = cardWidth - infoX - padding;
397
+
398
+ // Aplica efeito de glassmorphism se ativado
399
+ if (this.useGlassmorphism) {
400
+ applyGlassmorphism(
401
+ ctx,
402
+ infoX - padding / 2,
403
+ coverHeight - padding / 2,
404
+ cardWidth - infoX,
405
+ cardHeight - coverHeight + padding,
406
+ cornerRadius,
407
+ 0.2,
408
+ "#FFFFFF"
409
+ );
410
+ }
411
+
412
+ // --- Desenha Título ---
413
+ ctx.fillStyle = this.textColor;
414
+ ctx.font = `bold 24px ${registeredFontName}-Bold`;
415
+ ctx.textAlign = "left";
416
+ ctx.textBaseline = "top";
417
+
418
+ // Aplica sombra de texto se ativada
419
+ if (this.useTextShadow) {
420
+ applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 3, 1, 1);
421
+ }
422
+
423
+ const titleText = this.title;
424
+ const titleWidth = ctx.measureText(titleText).width;
425
+ ctx.fillText(titleText, infoX, infoY);
426
+
427
+ // Desenha ícone de verificado (se aplicável)
428
+ if (this.isVerified) {
429
+ const verifiedSize = 20;
430
+ const verifiedX = infoX + titleWidth + 10;
431
+
432
+ ctx.fillStyle = this.primaryColor;
433
+ ctx.beginPath();
434
+ ctx.arc(verifiedX + verifiedSize / 2, infoY + 12, verifiedSize / 2, 0, Math.PI * 2);
435
+ ctx.fill();
436
+
437
+ ctx.fillStyle = "#FFFFFF";
438
+ ctx.font = `bold 14px ${registeredFontName}-Bold`;
439
+ ctx.textAlign = "center";
440
+ ctx.fillText("✓", verifiedX + verifiedSize / 2, infoY + 12);
441
+ }
442
+
443
+ // Remove sombra para o próximo texto
444
+ if (this.useTextShadow) {
445
+ clearShadow(ctx);
446
+ }
447
+
448
+ // --- Desenha Descrição ---
449
+ if (this.description) {
450
+ ctx.fillStyle = this.secondaryTextColor;
451
+ ctx.font = `regular 16px ${registeredFontName}-Regular`;
452
+ ctx.textAlign = "left";
453
+
454
+ const descriptionY = infoY + 35;
455
+ wrapText(ctx, this.description, infoX, descriptionY, infoWidth, 20, registeredFontName);
456
+ }
457
+
458
+ // --- Desenha Contadores ---
459
+ const counterY = cardHeight - padding - 20;
460
+ let counterX = infoX;
461
+
462
+ // Contador de inscritos
463
+ if (this.subscribers > 0) {
464
+ ctx.fillStyle = this.secondaryTextColor;
465
+ ctx.font = `medium 16px ${registeredFontName}-Medium`;
466
+ ctx.textAlign = "left";
467
+
468
+ const subscribersText = `${this._formatNumber(this.subscribers)} inscritos`;
469
+ ctx.fillText(subscribersText, counterX, counterY);
470
+
471
+ counterX += ctx.measureText(subscribersText).width + 20;
472
+ }
473
+
474
+ // Contador de posts
475
+ if (this.posts > 0) {
476
+ ctx.fillStyle = this.secondaryTextColor;
477
+ ctx.font = `medium 16px ${registeredFontName}-Medium`;
478
+ ctx.textAlign = "left";
479
+
480
+ const postsText = `${this._formatNumber(this.posts)} publicações`;
481
+ ctx.fillText(postsText, counterX, counterY);
482
+ }
483
+
484
+ // --- Desenha Link ---
485
+ if (this.link) {
486
+ ctx.fillStyle = this.primaryColor;
487
+ ctx.font = `medium 16px ${registeredFontName}-Medium`;
488
+ ctx.textAlign = "right";
489
+ ctx.textBaseline = "bottom";
490
+
491
+ ctx.fillText(this.link, cardWidth - padding, cardHeight - padding);
492
+ }
493
+
494
+ // --- Desenha Ícone de Privacidade ---
495
+ const privacyIconSize = 16;
496
+ const privacyIconX = cardWidth - padding - privacyIconSize;
497
+ const privacyIconY = infoY;
498
+
499
+ ctx.fillStyle = this.isPublic ? this.primaryColor : this.secondaryTextColor;
500
+ ctx.beginPath();
501
+
502
+ if (this.isPublic) {
503
+ // Ícone de canal público (globo)
504
+ ctx.arc(privacyIconX + privacyIconSize / 2, privacyIconY + privacyIconSize / 2, privacyIconSize / 2, 0, Math.PI * 2);
505
+ ctx.fill();
506
+
507
+ ctx.strokeStyle = "#FFFFFF";
508
+ ctx.lineWidth = 1;
509
+ ctx.beginPath();
510
+ ctx.arc(privacyIconX + privacyIconSize / 2, privacyIconY + privacyIconSize / 2, privacyIconSize / 3, 0, Math.PI * 2);
511
+ ctx.stroke();
512
+ } else {
513
+ // Ícone de canal privado (cadeado)
514
+ ctx.fillRect(privacyIconX, privacyIconY + privacyIconSize / 3, privacyIconSize, privacyIconSize * 2/3);
515
+ ctx.beginPath();
516
+ ctx.arc(privacyIconX + privacyIconSize / 2, privacyIconY + privacyIconSize / 3, privacyIconSize / 3, Math.PI, 0);
517
+ ctx.fill();
518
+ }
519
+
520
+ // --- Codifica e Retorna Buffer ---
521
+ try {
522
+ return await encodeToBuffer(canvas);
523
+ } catch (err) {
524
+ console.error("Falha ao codificar o Cabeçalho do Telegram:", err);
525
+ throw new Error("Não foi possível gerar o buffer de imagem do Cabeçalho do Telegram.");
526
+ }
527
+ }
528
+
529
+ // --- Métodos Auxiliares Privados ---
530
+ /**
531
+ * Formata um número para exibição amigável
532
+ * @private
533
+ */
534
+ _formatNumber(num) {
535
+ if (num >= 1000000) {
536
+ return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
537
+ }
538
+ if (num >= 1000) {
539
+ return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
540
+ }
541
+ return num.toString();
542
+ }
543
+ };
544
+