fuelex-leaderboard 1.0.0
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.
- package/README.md +191 -0
- package/examples/basic.js +137 -0
- package/output/compact_leaderboard.png +0 -0
- package/output/economy_leaderboard.png +0 -0
- package/output/invites_leaderboard.png +0 -0
- package/output/levels_leaderboard.png +0 -0
- package/output/messages_leaderboard.png +0 -0
- package/output/voice_leaderboard.png +0 -0
- package/output/xp_leaderboard.png +0 -0
- package/package.json +36 -0
- package/src/components/LeaderboardCard.js +269 -0
- package/src/components/RankBadge.js +122 -0
- package/src/components/StatIcon.js +258 -0
- package/src/components/UserRow.js +104 -0
- package/src/core/theme.js +229 -0
- package/src/index.js +38 -0
- package/src/utils/effects.js +217 -0
- package/src/utils/fonts.js +147 -0
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Fuelex Leaderboard
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
**Premium Discord Leaderboard UI Package**
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/fuelex-leaderboard)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](https://discord.gg/fuelex)
|
|
12
|
+
|
|
13
|
+
*High-quality, customizable leaderboard image generator for Discord bots*
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## ✨ Features
|
|
20
|
+
|
|
21
|
+
- 🎨 **Premium Black & Yellow Theme** - Sleek, modern design with neon glow effects
|
|
22
|
+
- 🏆 **Tier-based Rank Styling** - Gold, Silver, Bronze gradients for top 3
|
|
23
|
+
- 📊 **Multiple Stat Types** - Voice, XP, Messages, Invites, Economy, Levels
|
|
24
|
+
- ⚡ **High Performance** - Built with `@napi-rs/canvas` for native speed
|
|
25
|
+
- 🔧 **Fully Customizable** - Easy to configure and extend
|
|
26
|
+
- 📱 **Discord Optimized** - Perfect dimensions for embeds and messages
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 📦 Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install fuelex-leaderboard
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 🚀 Quick Start
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
const { LeaderboardCard } = require('fuelex-leaderboard');
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
|
|
44
|
+
// Create a voice leaderboard
|
|
45
|
+
const leaderboard = new LeaderboardCard({
|
|
46
|
+
type: 'voice',
|
|
47
|
+
title: 'Voice Leaderboard',
|
|
48
|
+
users: [
|
|
49
|
+
{ rank: 1, userId: '123', username: 'DragonSlayer', value: 43200, isActive: true },
|
|
50
|
+
{ rank: 2, userId: '456', username: 'NightWolf', value: 38500, isActive: true },
|
|
51
|
+
{ rank: 3, userId: '789', username: 'PhoenixRider', value: 32100, isActive: false },
|
|
52
|
+
// ... more users
|
|
53
|
+
],
|
|
54
|
+
rowCount: 10,
|
|
55
|
+
footer: 'fuelex-bot.dev'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Generate the image
|
|
59
|
+
const buffer = await leaderboard.render();
|
|
60
|
+
fs.writeFileSync('leaderboard.png', buffer);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📋 Options
|
|
66
|
+
|
|
67
|
+
| Option | Type | Default | Description |
|
|
68
|
+
|--------|------|---------|-------------|
|
|
69
|
+
| `type` | `string` | `'xp'` | Leaderboard type: `voice`, `xp`, `messages`, `invites`, `economy`, `levels` |
|
|
70
|
+
| `title` | `string` | Auto-generated | Custom title for the leaderboard |
|
|
71
|
+
| `users` | `Array` | `[]` | Array of user objects |
|
|
72
|
+
| `rowCount` | `number` | `10` | Number of rows to display (5, 10, 15, 20) |
|
|
73
|
+
| `footer` | `string` | `null` | Optional footer text |
|
|
74
|
+
| `showHeader` | `boolean` | `true` | Whether to show the header section |
|
|
75
|
+
| `showAvatars` | `boolean` | `true` | Whether to display user avatars |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 👤 User Object
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
{
|
|
83
|
+
rank: 1, // User's rank position (1-indexed)
|
|
84
|
+
userId: '123456789', // Unique identifier
|
|
85
|
+
username: 'Player1', // Display name
|
|
86
|
+
value: 12345, // Stat value (formatted based on type)
|
|
87
|
+
avatar: 'https://...', // Optional avatar URL
|
|
88
|
+
isActive: true // Optional status indicator
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 🎨 Stat Types
|
|
95
|
+
|
|
96
|
+
| Type | Icon | Value Format |
|
|
97
|
+
|------|------|--------------|
|
|
98
|
+
| `voice` | 🔊 Speaker | Duration (12h 34m) |
|
|
99
|
+
| `xp` | ⭐ Star | Number with XP suffix |
|
|
100
|
+
| `messages` | 💬 Message | Plain number |
|
|
101
|
+
| `invites` | 👥 User | Plain number |
|
|
102
|
+
| `economy` | 💰 Coin | Currency format ($1,234) |
|
|
103
|
+
| `levels` | 🏆 Trophy | Level prefix (Lv. 50) |
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 🖼️ Output Formats
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
// PNG (default, best for Discord)
|
|
111
|
+
const pngBuffer = await leaderboard.render();
|
|
112
|
+
|
|
113
|
+
// JPEG (smaller file size)
|
|
114
|
+
const jpegBuffer = await leaderboard.renderJPEG(90); // quality: 0-100
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 🎯 Discord.js Integration
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
const { AttachmentBuilder } = require('discord.js');
|
|
123
|
+
const { LeaderboardCard } = require('fuelex-leaderboard');
|
|
124
|
+
|
|
125
|
+
async function sendLeaderboard(interaction) {
|
|
126
|
+
const leaderboard = new LeaderboardCard({
|
|
127
|
+
type: 'voice',
|
|
128
|
+
users: await fetchTopUsers(),
|
|
129
|
+
rowCount: 10
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const buffer = await leaderboard.render();
|
|
133
|
+
const attachment = new AttachmentBuilder(buffer, { name: 'leaderboard.png' });
|
|
134
|
+
|
|
135
|
+
await interaction.reply({ files: [attachment] });
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 🎨 Color Palette
|
|
142
|
+
|
|
143
|
+
| Element | Color | Hex |
|
|
144
|
+
|---------|-------|-----|
|
|
145
|
+
| Background | Deep Matte Black | `#0A0A0A` |
|
|
146
|
+
| Card | Charcoal | `#1A1A1A` |
|
|
147
|
+
| Neon Yellow | Primary Accent | `#FFEA00` |
|
|
148
|
+
| Gold (#1) | Gradient | `#FFD700 → #FFA500` |
|
|
149
|
+
| Silver (#2) | Muted | `#C0C0C0` |
|
|
150
|
+
| Bronze (#3) | Warm | `#CD7F32` |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 📁 Project Structure
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
fuelex-leaderboard/
|
|
158
|
+
├── src/
|
|
159
|
+
│ ├── index.js # Main entry point
|
|
160
|
+
│ ├── core/
|
|
161
|
+
│ │ └── theme.js # Colors, constants, utilities
|
|
162
|
+
│ ├── components/
|
|
163
|
+
│ │ ├── LeaderboardCard.js
|
|
164
|
+
│ │ ├── RankBadge.js
|
|
165
|
+
│ │ ├── StatIcon.js
|
|
166
|
+
│ │ └── UserRow.js
|
|
167
|
+
│ └── utils/
|
|
168
|
+
│ ├── effects.js # Glow, shadows, gradients
|
|
169
|
+
│ └── fonts.js # Font management
|
|
170
|
+
├── examples/
|
|
171
|
+
│ └── basic.js # Usage examples
|
|
172
|
+
├── output/ # Generated images
|
|
173
|
+
├── package.json
|
|
174
|
+
└── README.md
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 🔗 Links
|
|
180
|
+
|
|
181
|
+
- **Website**: [fuelex-bot.dev](https://fuelex-bot.dev)
|
|
182
|
+
- **Discord**: [Join Server](https://discord.gg/fuelex)
|
|
183
|
+
- **GitHub**: [fuelex/fuelex-leaderboard](https://github.com/fuelex/fuelex-leaderboard)
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
<div align="center">
|
|
188
|
+
|
|
189
|
+
**Built with 💛 by the Fuelex Team**
|
|
190
|
+
|
|
191
|
+
</div>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuelex Leaderboard - Example Usage
|
|
3
|
+
* Demonstrates generating leaderboard images
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { LeaderboardCard } = require('../src/index');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
// Sample user data
|
|
11
|
+
const sampleUsers = [
|
|
12
|
+
{ rank: 1, userId: '1', username: 'DragonSlayer', value: 43200, isActive: true },
|
|
13
|
+
{ rank: 2, userId: '2', username: 'NightWolf', value: 38500, isActive: true },
|
|
14
|
+
{ rank: 3, userId: '3', username: 'PhoenixRider', value: 32100, isActive: false },
|
|
15
|
+
{ rank: 4, userId: '4', username: 'ShadowHunter', value: 28900, isActive: true },
|
|
16
|
+
{ rank: 5, userId: '5', username: 'CrystalMage', value: 25400, isActive: true },
|
|
17
|
+
{ rank: 6, userId: '6', username: 'StormBringer', value: 21800, isActive: false },
|
|
18
|
+
{ rank: 7, userId: '7', username: 'FrostKnight', value: 18200, isActive: true },
|
|
19
|
+
{ rank: 8, userId: '8', username: 'BlazeMaster', value: 15600, isActive: true },
|
|
20
|
+
{ rank: 9, userId: '9', username: 'VoidWalker', value: 12300, isActive: false },
|
|
21
|
+
{ rank: 10, userId: '10', username: 'ThunderGod', value: 9800, isActive: true }
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
async function generateExamples() {
|
|
25
|
+
const outputDir = path.join(__dirname, '../output');
|
|
26
|
+
|
|
27
|
+
// Create output directory if it doesn't exist
|
|
28
|
+
if (!fs.existsSync(outputDir)) {
|
|
29
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log('🎨 Generating Fuelex Leaderboard examples...\n');
|
|
33
|
+
|
|
34
|
+
// Generate Voice Leaderboard
|
|
35
|
+
console.log('📊 Creating Voice Leaderboard...');
|
|
36
|
+
const voiceLeaderboard = new LeaderboardCard({
|
|
37
|
+
type: 'voice',
|
|
38
|
+
title: 'Voice Leaderboard',
|
|
39
|
+
users: sampleUsers,
|
|
40
|
+
rowCount: 10,
|
|
41
|
+
footer: 'fuelex-bot.dev'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const voiceBuffer = await voiceLeaderboard.render();
|
|
45
|
+
fs.writeFileSync(path.join(outputDir, 'voice_leaderboard.png'), voiceBuffer);
|
|
46
|
+
console.log(' ✓ Saved: voice_leaderboard.png');
|
|
47
|
+
|
|
48
|
+
// Generate XP Leaderboard
|
|
49
|
+
console.log('📊 Creating XP Leaderboard...');
|
|
50
|
+
const xpLeaderboard = new LeaderboardCard({
|
|
51
|
+
type: 'xp',
|
|
52
|
+
title: 'XP Leaderboard',
|
|
53
|
+
users: sampleUsers.map(u => ({ ...u, value: u.value * 10 })),
|
|
54
|
+
rowCount: 10,
|
|
55
|
+
footer: 'fuelex-bot.dev'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const xpBuffer = await xpLeaderboard.render();
|
|
59
|
+
fs.writeFileSync(path.join(outputDir, 'xp_leaderboard.png'), xpBuffer);
|
|
60
|
+
console.log(' ✓ Saved: xp_leaderboard.png');
|
|
61
|
+
|
|
62
|
+
// Generate Economy Leaderboard
|
|
63
|
+
console.log('📊 Creating Economy Leaderboard...');
|
|
64
|
+
const economyLeaderboard = new LeaderboardCard({
|
|
65
|
+
type: 'economy',
|
|
66
|
+
title: 'Richest Members',
|
|
67
|
+
users: sampleUsers.map(u => ({ ...u, value: u.value * 100 })),
|
|
68
|
+
rowCount: 10,
|
|
69
|
+
footer: 'fuelex-bot.dev'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const economyBuffer = await economyLeaderboard.render();
|
|
73
|
+
fs.writeFileSync(path.join(outputDir, 'economy_leaderboard.png'), economyBuffer);
|
|
74
|
+
console.log(' ✓ Saved: economy_leaderboard.png');
|
|
75
|
+
|
|
76
|
+
// Generate Messages Leaderboard
|
|
77
|
+
console.log('📊 Creating Messages Leaderboard...');
|
|
78
|
+
const messagesLeaderboard = new LeaderboardCard({
|
|
79
|
+
type: 'messages',
|
|
80
|
+
title: 'Most Active Chatters',
|
|
81
|
+
users: sampleUsers.map(u => ({ ...u, value: Math.floor(u.value / 10) })),
|
|
82
|
+
rowCount: 10,
|
|
83
|
+
footer: 'fuelex-bot.dev'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const messagesBuffer = await messagesLeaderboard.render();
|
|
87
|
+
fs.writeFileSync(path.join(outputDir, 'messages_leaderboard.png'), messagesBuffer);
|
|
88
|
+
console.log(' ✓ Saved: messages_leaderboard.png');
|
|
89
|
+
|
|
90
|
+
// Generate Invites Leaderboard
|
|
91
|
+
console.log('📊 Creating Invites Leaderboard...');
|
|
92
|
+
const invitesLeaderboard = new LeaderboardCard({
|
|
93
|
+
type: 'invites',
|
|
94
|
+
title: 'Top Inviters',
|
|
95
|
+
users: sampleUsers.map(u => ({ ...u, value: Math.floor(u.value / 1000) })),
|
|
96
|
+
rowCount: 10,
|
|
97
|
+
footer: 'fuelex-bot.dev'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const invitesBuffer = await invitesLeaderboard.render();
|
|
101
|
+
fs.writeFileSync(path.join(outputDir, 'invites_leaderboard.png'), invitesBuffer);
|
|
102
|
+
console.log(' ✓ Saved: invites_leaderboard.png');
|
|
103
|
+
|
|
104
|
+
// Generate Levels Leaderboard
|
|
105
|
+
console.log('📊 Creating Levels Leaderboard...');
|
|
106
|
+
const levelsLeaderboard = new LeaderboardCard({
|
|
107
|
+
type: 'levels',
|
|
108
|
+
title: 'Top Levels',
|
|
109
|
+
users: sampleUsers.map((u, i) => ({ ...u, value: 100 - i * 8 })),
|
|
110
|
+
rowCount: 10,
|
|
111
|
+
footer: 'fuelex-bot.dev'
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const levelsBuffer = await levelsLeaderboard.render();
|
|
115
|
+
fs.writeFileSync(path.join(outputDir, 'levels_leaderboard.png'), levelsBuffer);
|
|
116
|
+
console.log(' ✓ Saved: levels_leaderboard.png');
|
|
117
|
+
|
|
118
|
+
// Generate Compact (5 rows)
|
|
119
|
+
console.log('📊 Creating Compact Leaderboard (5 rows)...');
|
|
120
|
+
const compactLeaderboard = new LeaderboardCard({
|
|
121
|
+
type: 'xp',
|
|
122
|
+
title: 'Top 5 Players',
|
|
123
|
+
users: sampleUsers.slice(0, 5),
|
|
124
|
+
rowCount: 5,
|
|
125
|
+
footer: 'fuelex-bot.dev'
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const compactBuffer = await compactLeaderboard.render();
|
|
129
|
+
fs.writeFileSync(path.join(outputDir, 'compact_leaderboard.png'), compactBuffer);
|
|
130
|
+
console.log(' ✓ Saved: compact_leaderboard.png');
|
|
131
|
+
|
|
132
|
+
console.log('\n✨ All examples generated successfully!');
|
|
133
|
+
console.log(`📁 Output directory: ${outputDir}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Run examples
|
|
137
|
+
generateExamples().catch(console.error);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fuelex-leaderboard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Premium Discord leaderboard UI generator for Fuelex Bot - Black & Yellow theme",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node test/generate.js",
|
|
8
|
+
"example": "node examples/basic.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"discord",
|
|
12
|
+
"leaderboard",
|
|
13
|
+
"fuelex",
|
|
14
|
+
"canvas",
|
|
15
|
+
"image",
|
|
16
|
+
"generator",
|
|
17
|
+
"bot",
|
|
18
|
+
"voice",
|
|
19
|
+
"xp",
|
|
20
|
+
"levels",
|
|
21
|
+
"economy"
|
|
22
|
+
],
|
|
23
|
+
"author": "Fuelex Team",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"homepage": "https://fuelex-bot.dev",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/fuelex/fuelex-leaderboard"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@napi-rs/canvas": "^0.1.65"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuelex Leaderboard - Main Leaderboard Card Component
|
|
3
|
+
* Premium Discord leaderboard image generator
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createCanvas, loadImage } = require('@napi-rs/canvas');
|
|
7
|
+
const { COLORS, SIZES, getStatConfig } = require('../core/theme');
|
|
8
|
+
const { drawRoundedRect, applyShadow, clearShadow } = require('../utils/effects');
|
|
9
|
+
const { registerFonts, getFont } = require('../utils/fonts');
|
|
10
|
+
const { drawUserRow } = require('./UserRow');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* LeaderboardCard - Main class for generating leaderboard images
|
|
14
|
+
*/
|
|
15
|
+
class LeaderboardCard {
|
|
16
|
+
/**
|
|
17
|
+
* Create a new LeaderboardCard
|
|
18
|
+
* @param {object} options - Leaderboard options
|
|
19
|
+
* @param {string} options.type - Leaderboard type (voice, xp, messages, invites, economy, levels)
|
|
20
|
+
* @param {string} [options.title] - Custom title (auto-generated if not provided)
|
|
21
|
+
* @param {Array} options.users - Array of user data
|
|
22
|
+
* @param {number} [options.rowCount=10] - Number of rows to display
|
|
23
|
+
* @param {string} [options.footer] - Footer text
|
|
24
|
+
* @param {boolean} [options.showHeader=true] - Whether to show the header
|
|
25
|
+
* @param {boolean} [options.showAvatars=true] - Whether to show user avatars
|
|
26
|
+
*/
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.type = options.type || 'xp';
|
|
29
|
+
this.title = options.title || this._generateTitle();
|
|
30
|
+
this.users = options.users || [];
|
|
31
|
+
this.rowCount = options.rowCount || 10;
|
|
32
|
+
this.footer = options.footer || null;
|
|
33
|
+
this.showHeader = options.showHeader !== false;
|
|
34
|
+
this.showAvatars = options.showAvatars !== false;
|
|
35
|
+
|
|
36
|
+
// Calculate dimensions
|
|
37
|
+
this.width = SIZES.cardWidth;
|
|
38
|
+
this.headerHeight = this.showHeader ? 60 : 0;
|
|
39
|
+
this.footerHeight = this.footer ? 40 : 0;
|
|
40
|
+
this.rowHeight = SIZES.rowHeight;
|
|
41
|
+
this.rowGap = SIZES.rowGap;
|
|
42
|
+
this.padding = SIZES.cardPadding;
|
|
43
|
+
|
|
44
|
+
// Register fonts on instantiation
|
|
45
|
+
registerFonts();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate default title based on type
|
|
50
|
+
* @returns {string} Generated title
|
|
51
|
+
* @private
|
|
52
|
+
*/
|
|
53
|
+
_generateTitle() {
|
|
54
|
+
const statConfig = getStatConfig(this.type);
|
|
55
|
+
return `${statConfig.label} Leaderboard`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Calculate total canvas height
|
|
60
|
+
* @returns {number} Canvas height
|
|
61
|
+
* @private
|
|
62
|
+
*/
|
|
63
|
+
_calculateHeight() {
|
|
64
|
+
const displayCount = Math.min(this.users.length, this.rowCount);
|
|
65
|
+
const rowsHeight = displayCount * this.rowHeight + (displayCount - 1) * this.rowGap;
|
|
66
|
+
return this.padding * 2 + this.headerHeight + rowsHeight + this.footerHeight;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Draw the header section
|
|
71
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
|
72
|
+
* @private
|
|
73
|
+
*/
|
|
74
|
+
_drawHeader(ctx) {
|
|
75
|
+
if (!this.showHeader) return;
|
|
76
|
+
|
|
77
|
+
const statConfig = getStatConfig(this.type);
|
|
78
|
+
|
|
79
|
+
// Draw title
|
|
80
|
+
ctx.font = getFont(SIZES.headerFontSize, 'bold');
|
|
81
|
+
ctx.textAlign = 'left';
|
|
82
|
+
ctx.textBaseline = 'middle';
|
|
83
|
+
|
|
84
|
+
// Title with glow
|
|
85
|
+
ctx.save();
|
|
86
|
+
ctx.shadowColor = COLORS.glowYellow;
|
|
87
|
+
ctx.shadowBlur = 10;
|
|
88
|
+
ctx.fillStyle = COLORS.goldenYellow;
|
|
89
|
+
ctx.fillText(this.title, this.padding, this.padding + 30);
|
|
90
|
+
ctx.restore();
|
|
91
|
+
|
|
92
|
+
// Draw type indicator icon
|
|
93
|
+
const { drawIcon } = require('./StatIcon');
|
|
94
|
+
drawIcon(
|
|
95
|
+
ctx,
|
|
96
|
+
statConfig.icon,
|
|
97
|
+
this.width - this.padding - 30,
|
|
98
|
+
this.padding + 18,
|
|
99
|
+
24,
|
|
100
|
+
COLORS.neonYellow,
|
|
101
|
+
true
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Draw the footer section
|
|
107
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
|
108
|
+
* @param {number} y - Y position for footer
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
_drawFooter(ctx, y) {
|
|
112
|
+
if (!this.footer) return;
|
|
113
|
+
|
|
114
|
+
ctx.font = getFont(14, 'regular');
|
|
115
|
+
ctx.textAlign = 'center';
|
|
116
|
+
ctx.textBaseline = 'middle';
|
|
117
|
+
ctx.fillStyle = COLORS.textMuted;
|
|
118
|
+
ctx.fillText(this.footer, this.width / 2, y + 20);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Draw the main background
|
|
123
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
|
124
|
+
* @param {number} height - Canvas height
|
|
125
|
+
* @private
|
|
126
|
+
*/
|
|
127
|
+
_drawBackground(ctx, height) {
|
|
128
|
+
// Main background
|
|
129
|
+
ctx.fillStyle = COLORS.primaryBg;
|
|
130
|
+
ctx.fillRect(0, 0, this.width, height);
|
|
131
|
+
|
|
132
|
+
// Subtle gradient overlay
|
|
133
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
134
|
+
gradient.addColorStop(0, 'rgba(255, 215, 0, 0.03)');
|
|
135
|
+
gradient.addColorStop(0.5, 'transparent');
|
|
136
|
+
gradient.addColorStop(1, 'rgba(255, 215, 0, 0.02)');
|
|
137
|
+
ctx.fillStyle = gradient;
|
|
138
|
+
ctx.fillRect(0, 0, this.width, height);
|
|
139
|
+
|
|
140
|
+
// Outer border glow
|
|
141
|
+
ctx.save();
|
|
142
|
+
ctx.strokeStyle = COLORS.goldenYellow;
|
|
143
|
+
ctx.lineWidth = 2;
|
|
144
|
+
ctx.shadowColor = COLORS.glowGold;
|
|
145
|
+
ctx.shadowBlur = 15;
|
|
146
|
+
ctx.beginPath();
|
|
147
|
+
ctx.roundRect(1, 1, this.width - 2, height - 2, 16);
|
|
148
|
+
ctx.stroke();
|
|
149
|
+
ctx.restore();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Load avatar images for users
|
|
154
|
+
* @returns {Promise<Map>} Map of userId to loaded image
|
|
155
|
+
* @private
|
|
156
|
+
*/
|
|
157
|
+
async _loadAvatars() {
|
|
158
|
+
const avatarMap = new Map();
|
|
159
|
+
|
|
160
|
+
if (!this.showAvatars) return avatarMap;
|
|
161
|
+
|
|
162
|
+
const loadPromises = this.users.slice(0, this.rowCount).map(async (user) => {
|
|
163
|
+
if (user.avatar) {
|
|
164
|
+
try {
|
|
165
|
+
const img = await loadImage(user.avatar);
|
|
166
|
+
avatarMap.set(user.userId, img);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// Failed to load avatar, will render without it
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await Promise.all(loadPromises);
|
|
174
|
+
return avatarMap;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render the leaderboard to a canvas buffer
|
|
179
|
+
* @returns {Promise<Buffer>} PNG image buffer
|
|
180
|
+
*/
|
|
181
|
+
async render() {
|
|
182
|
+
const height = this._calculateHeight();
|
|
183
|
+
const canvas = createCanvas(this.width, height);
|
|
184
|
+
const ctx = canvas.getContext('2d');
|
|
185
|
+
|
|
186
|
+
// Draw background
|
|
187
|
+
this._drawBackground(ctx, height);
|
|
188
|
+
|
|
189
|
+
// Draw header
|
|
190
|
+
this._drawHeader(ctx);
|
|
191
|
+
|
|
192
|
+
// Load avatars
|
|
193
|
+
const avatarMap = await this._loadAvatars();
|
|
194
|
+
|
|
195
|
+
// Draw user rows
|
|
196
|
+
const startY = this.padding + this.headerHeight;
|
|
197
|
+
const rowWidth = this.width - this.padding * 2;
|
|
198
|
+
const displayUsers = this.users.slice(0, this.rowCount);
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < displayUsers.length; i++) {
|
|
201
|
+
const user = displayUsers[i];
|
|
202
|
+
const rowY = startY + i * (this.rowHeight + this.rowGap);
|
|
203
|
+
const avatarImg = avatarMap.get(user.userId) || null;
|
|
204
|
+
|
|
205
|
+
await drawUserRow(
|
|
206
|
+
ctx,
|
|
207
|
+
user,
|
|
208
|
+
this.padding,
|
|
209
|
+
rowY,
|
|
210
|
+
rowWidth,
|
|
211
|
+
this.rowHeight,
|
|
212
|
+
this.type,
|
|
213
|
+
avatarImg
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Draw footer
|
|
218
|
+
const footerY = startY + displayUsers.length * (this.rowHeight + this.rowGap);
|
|
219
|
+
this._drawFooter(ctx, footerY);
|
|
220
|
+
|
|
221
|
+
return canvas.toBuffer('image/png');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Render to JPEG format
|
|
226
|
+
* @param {number} quality - JPEG quality (0-100)
|
|
227
|
+
* @returns {Promise<Buffer>} JPEG image buffer
|
|
228
|
+
*/
|
|
229
|
+
async renderJPEG(quality = 90) {
|
|
230
|
+
const height = this._calculateHeight();
|
|
231
|
+
const canvas = createCanvas(this.width, height);
|
|
232
|
+
const ctx = canvas.getContext('2d');
|
|
233
|
+
|
|
234
|
+
// Draw everything
|
|
235
|
+
this._drawBackground(ctx, height);
|
|
236
|
+
this._drawHeader(ctx);
|
|
237
|
+
|
|
238
|
+
const avatarMap = await this._loadAvatars();
|
|
239
|
+
const startY = this.padding + this.headerHeight;
|
|
240
|
+
const rowWidth = this.width - this.padding * 2;
|
|
241
|
+
const displayUsers = this.users.slice(0, this.rowCount);
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < displayUsers.length; i++) {
|
|
244
|
+
const user = displayUsers[i];
|
|
245
|
+
const rowY = startY + i * (this.rowHeight + this.rowGap);
|
|
246
|
+
const avatarImg = avatarMap.get(user.userId) || null;
|
|
247
|
+
|
|
248
|
+
await drawUserRow(ctx, user, this.padding, rowY, rowWidth, this.rowHeight, this.type, avatarImg);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const footerY = startY + displayUsers.length * (this.rowHeight + this.rowGap);
|
|
252
|
+
this._drawFooter(ctx, footerY);
|
|
253
|
+
|
|
254
|
+
return canvas.toBuffer('image/jpeg', { quality: quality / 100 });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get the canvas dimensions
|
|
259
|
+
* @returns {object} Width and height
|
|
260
|
+
*/
|
|
261
|
+
getDimensions() {
|
|
262
|
+
return {
|
|
263
|
+
width: this.width,
|
|
264
|
+
height: this._calculateHeight()
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = { LeaderboardCard };
|