Better dev experience, better front page
All checks were successful
pipeline / build-and-push-images (push) Successful in 5m30s
All checks were successful
pipeline / build-and-push-images (push) Successful in 5m30s
This commit is contained in:
3
dev/.gitignore
vendored
Normal file
3
dev/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
data/db
|
||||||
|
data
|
||||||
40
dev/README.md
Normal file
40
dev/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# BuildPath Development Database Setup
|
||||||
|
|
||||||
|
This directory contains scripts and tools for setting up a development MongoDB instance with realistic data for frontend testing.
|
||||||
|
|
||||||
|
## 📁 Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dev/
|
||||||
|
├── data/ # Data files (patches.json, match files, db data)
|
||||||
|
├── scripts/ # Setup and import scripts
|
||||||
|
│ ├── setup-db.js # Main setup script
|
||||||
|
│ ├── process-matches.js # Stream-based match importer
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dev/scripts/setup-db.js
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Download a production snapshot with realistic data
|
||||||
|
2. Import patches data
|
||||||
|
3. Import matches using stream processing (optimized for large files)
|
||||||
|
4. Generate champion statistics
|
||||||
|
|
||||||
|
## Individual Commands
|
||||||
|
|
||||||
|
### Generate Stats Only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dev/scripts/setup-db.js generate-stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Database Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dev/scripts/setup-db.js status
|
||||||
|
```
|
||||||
32
dev/docker-compose.yml
Normal file
32
dev/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
# Development MongoDB with performance optimizations
|
||||||
|
mongodb:
|
||||||
|
image: mongo:latest
|
||||||
|
container_name: buildpath-mongodb
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-root}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS:-password}
|
||||||
|
volumes:
|
||||||
|
- ./data/db:/data/db
|
||||||
|
command: mongod --wiredTigerCacheSizeGB 4 --quiet
|
||||||
|
healthcheck:
|
||||||
|
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
mongo-express:
|
||||||
|
image: mongo-express
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
ME_CONFIG_MONGODB_SERVER: mongodb
|
||||||
|
ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_USER:-root}
|
||||||
|
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASS:-password}
|
||||||
|
ME_CONFIG_BASICAUTH_USERNAME: admin
|
||||||
|
ME_CONFIG_BASICAUTH_PASSWORD: admin123
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
168
dev/package-lock.json
generated
Normal file
168
dev/package-lock.json
generated
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"name": "buildpath-dev-tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "buildpath-dev-tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"mongodb": "^6.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mongodb-js/saslprep": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==",
|
||||||
|
"dependencies": {
|
||||||
|
"sparse-bitfield": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||||
|
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webidl-conversions": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/whatwg-url": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/webidl-conversions": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bson": {
|
||||||
|
"version": "6.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
|
||||||
|
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/memory-pager": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||||
|
},
|
||||||
|
"node_modules/mongodb": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@mongodb-js/saslprep": "^1.3.0",
|
||||||
|
"bson": "^6.10.4",
|
||||||
|
"mongodb-connection-string-url": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aws-sdk/credential-providers": "^3.188.0",
|
||||||
|
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
|
||||||
|
"gcp-metadata": "^5.2.0",
|
||||||
|
"kerberos": "^2.0.1",
|
||||||
|
"mongodb-client-encryption": ">=6.0.0 <7",
|
||||||
|
"snappy": "^7.3.2",
|
||||||
|
"socks": "^2.7.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@aws-sdk/credential-providers": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mongodb-js/zstd": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"gcp-metadata": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"kerberos": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mongodb-client-encryption": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"snappy": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/whatwg-url": "^11.0.2",
|
||||||
|
"whatwg-url": "^14.1.0 || ^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/punycode": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sparse-bitfield": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"memory-pager": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "^5.1.0",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
dev/package.json
Normal file
19
dev/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "buildpath-dev-tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Development tools for BuildPath database setup",
|
||||||
|
"main": "scripts/setup-db.js",
|
||||||
|
"scripts": {
|
||||||
|
"setup": "node scripts/setup-db.js",
|
||||||
|
"import-matches": "node scripts/setup-db.js import-matches",
|
||||||
|
"import-patches": "node scripts/setup-db.js import-patches",
|
||||||
|
"generate-stats": "node scripts/setup-db.js generate-stats",
|
||||||
|
"status": "node scripts/setup-db.js status"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mongodb": "^6.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
144
dev/scripts/process-matches.js
Normal file
144
dev/scripts/process-matches.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { MongoClient, ObjectId } = require('mongodb');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { createReadStream } = require('fs');
|
||||||
|
const { createInterface } = require('readline');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream-based import of large JSON files
|
||||||
|
* Optimized for 9GB+ files with minimal memory usage
|
||||||
|
*/
|
||||||
|
async function importLargeJsonFile(filePath, collectionName, batchSize = 1000) {
|
||||||
|
console.log(` 📁 File: ${filePath}`);
|
||||||
|
console.log(` 📦 Collection: ${collectionName}`);
|
||||||
|
console.log(` 🔄 Batch Size: ${batchSize}`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let processed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
// Connect to MongoDB
|
||||||
|
const client = new MongoClient(process.env.MONGO_URI || 'mongodb://root:password@localhost:27017/buildpath?authSource=admin');
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const db = client.db('matches');
|
||||||
|
const collection = db.collection(collectionName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create indexes first for better performance
|
||||||
|
await collection.createIndex({ "metadata.matchId": 1 }, { unique: true });
|
||||||
|
await collection.createIndex({ "info.gameDuration": 1 });
|
||||||
|
await collection.createIndex({ "info.participants.championId": 1 });
|
||||||
|
await collection.createIndex({ "info.participants.win": 1 });
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
const fileStats = fs.statSync(filePath);
|
||||||
|
const fileSize = (fileStats.size / (1024 * 1024 * 1024)).toFixed(2);
|
||||||
|
console.log(` 📊 File size: ${fileSize} GB`);
|
||||||
|
|
||||||
|
await processLineDelimitedFormat(filePath, collection, batchSize, startTime);
|
||||||
|
|
||||||
|
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log(`🎉 Import complete in ${totalTime} seconds`);
|
||||||
|
console.log(`✅ Processed: ${processed.toLocaleString()} matches`);
|
||||||
|
if (skipped > 0) {
|
||||||
|
console.log(`⚠️ Skipped: ${skipped.toLocaleString()} invalid entries`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Import failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processLineDelimitedFormat(filePath, collection, batchSize, startTime) {
|
||||||
|
const fileStream = createReadStream(filePath);
|
||||||
|
const rl = createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
let batch = [];
|
||||||
|
let lineCount = 0;
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
lineCount++;
|
||||||
|
process.stdout.write(`\r Processing line ${lineCount.toLocaleString()}... `);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (line.trim() === '') continue;
|
||||||
|
|
||||||
|
const match = JSON.parse(line);
|
||||||
|
if (!match.metadata || !match.metadata.matchId) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert $oid fields to proper ObjectId format
|
||||||
|
if (match._id && match._id.$oid) {
|
||||||
|
match._id = new ObjectId(match._id.$oid);
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.push(match);
|
||||||
|
|
||||||
|
if (batch.length >= batchSize) {
|
||||||
|
process.stdout.write(`\r Inserting batch into MongoDB... `);
|
||||||
|
await insertBatch(batch, collection);
|
||||||
|
batch = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert remaining matches
|
||||||
|
if (batch.length > 0) {
|
||||||
|
await insertBatch(batch, collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertBatch(batch, collection) {
|
||||||
|
if (batch.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await collection.insertMany(batch, {
|
||||||
|
ordered: false, // Continue on errors
|
||||||
|
writeConcern: { w: 1 } // Acknowledge writes
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 11000) {
|
||||||
|
// Duplicate matches - skip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ Batch insert error: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the import if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length < 2) {
|
||||||
|
console.log('Usage: node process-matches.js <file-path> <collection-name> [batch-size]');
|
||||||
|
console.log('Example: node process-matches.js ../data/16_1_1_matches.json 16.1.1 1000');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.resolve(args[0]);
|
||||||
|
const collectionName = args[1];
|
||||||
|
const batchSize = args[2] ? parseInt(args[2]) : 1000;
|
||||||
|
|
||||||
|
importLargeJsonFile(filePath, collectionName, batchSize)
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Import failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { importLargeJsonFile };
|
||||||
442
dev/scripts/setup-db.js
Normal file
442
dev/scripts/setup-db.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { MongoClient } = require('mongodb');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const https = require('https');
|
||||||
|
const tar = require('tar');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main database setup script
|
||||||
|
* Orchestrates the complete data import and stats generation process
|
||||||
|
*/
|
||||||
|
async function setupDatabase() {
|
||||||
|
console.log('🚀 Starting BuildPath database setup...');
|
||||||
|
console.log('=====================================');
|
||||||
|
|
||||||
|
// 1. Get latest patch version
|
||||||
|
const latestPatch = await getLatestPatchVersion();
|
||||||
|
console.log(`🎯 Latest patch version: ${latestPatch}`);
|
||||||
|
|
||||||
|
// 2. Check if data directory exists and has files
|
||||||
|
console.log('🔍 Checking for data files...');
|
||||||
|
const dataDir = path.join(__dirname, '../data');
|
||||||
|
const dataFiles = [
|
||||||
|
{ path: 'patches.json', required: true, description: 'Patches data' },
|
||||||
|
{ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create data directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let filesExist = true;
|
||||||
|
for (const file of dataFiles) {
|
||||||
|
const fullPath = path.join(dataDir, file.path);
|
||||||
|
if (file.required && !fs.existsSync(fullPath)) {
|
||||||
|
filesExist = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filesExist) {
|
||||||
|
console.log('📥 No data files found. Downloading latest snapshot...');
|
||||||
|
await downloadAndExtractSnapshot();
|
||||||
|
} else {
|
||||||
|
console.log('✅ Data files found');
|
||||||
|
for (const file of dataFiles) {
|
||||||
|
const fullPath = path.join(dataDir, file.path);
|
||||||
|
const stats = fs.statSync(fullPath);
|
||||||
|
const size = (stats.size / (1024 * 1024 * 1024)).toFixed(2);
|
||||||
|
console.log(`✅ Found ${file.description}: ${size} GB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start MongoDB if not running
|
||||||
|
console.log('🔄 Ensuring MongoDB is running...');
|
||||||
|
try {
|
||||||
|
execSync('docker compose up -d mongodb', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: path.join(__dirname, '..')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log('MongoDB service status:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Wait for MongoDB to be ready
|
||||||
|
await waitForMongoDB();
|
||||||
|
|
||||||
|
// 5. Import patches data
|
||||||
|
console.log('📦 Importing patches data...');
|
||||||
|
await importPatchesData();
|
||||||
|
|
||||||
|
// 6. Check existing matches count and import if needed
|
||||||
|
console.log('Checking existing matches count...');
|
||||||
|
const matchCount = await getMatchCount(latestPatch);
|
||||||
|
console.log(`📊 Current matches in database: ${matchCount}`);
|
||||||
|
|
||||||
|
if (matchCount < 100) {
|
||||||
|
console.log('📥 Importing matches (this may take a while)...');
|
||||||
|
await importMatchesData(latestPatch);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Skipping matches import - sufficient data already present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Run match collector to generate stats
|
||||||
|
console.log('📊 Generating champion stats...');
|
||||||
|
await generateChampionStats();
|
||||||
|
|
||||||
|
console.log('🎉 Database setup complete!');
|
||||||
|
console.log('=====================================');
|
||||||
|
console.log('📊 Your development database is ready!');
|
||||||
|
console.log('🔗 Connect to MongoDB: mongodb://root:password@localhost:27017');
|
||||||
|
console.log('🌐 Access Mongo Express: http://localhost:8081');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLatestPatchVersion() {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(__dirname, '../data/patches.json');
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// Check if it's line-delimited JSON or array format
|
||||||
|
let patchesData;
|
||||||
|
if (fileContent.trim().startsWith('[')) {
|
||||||
|
// Array format
|
||||||
|
patchesData = JSON.parse(fileContent);
|
||||||
|
if (!Array.isArray(patchesData)) {
|
||||||
|
throw new Error('Patches data should be an array');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Line-delimited JSON format
|
||||||
|
patchesData = fileContent.split('\n')
|
||||||
|
.filter(line => line.trim() !== '')
|
||||||
|
.map(line => JSON.parse(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert dates to Date objects for proper sorting
|
||||||
|
patchesData = patchesData.map(patch => ({
|
||||||
|
...patch,
|
||||||
|
date: new Date(patch.date.$date || patch.date)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort patches by date (newest first) and get the latest
|
||||||
|
const sortedPatches = patchesData.sort((a, b) => b.date - a.date);
|
||||||
|
const latestPatch = sortedPatches[0];
|
||||||
|
|
||||||
|
if (!latestPatch || !latestPatch.patch) {
|
||||||
|
throw new Error('Could not find patch version in patches data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestPatch.patch;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to get latest patch version:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndExtractSnapshot() {
|
||||||
|
const snapshotUrl = 'https://vhaudiquet.fr/public/buildpath-dev-snapshot.tar.xz';
|
||||||
|
const dataDir = path.join(__dirname, '../data');
|
||||||
|
const tempFile = path.join(dataDir, 'buildpath-dev-snapshot.tar.xz');
|
||||||
|
const extractDir = dataDir;
|
||||||
|
|
||||||
|
console.log('📥 Downloading snapshot...');
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const file = fs.createWriteStream(tempFile);
|
||||||
|
https.get(snapshotUrl, (response) => {
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Failed to download snapshot: ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}).on('error', (error) => {
|
||||||
|
fs.unlink(tempFile, () => {});
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Download complete. Extracting...');
|
||||||
|
|
||||||
|
// Extract the tar.xz file
|
||||||
|
await tar.x({
|
||||||
|
file: tempFile,
|
||||||
|
cwd: extractDir,
|
||||||
|
strip: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up the downloaded file
|
||||||
|
fs.unlinkSync(tempFile);
|
||||||
|
|
||||||
|
console.log('✅ Extraction complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMongoDB() {
|
||||||
|
const client = new MongoClient(getMongoUri());
|
||||||
|
let retries = 30;
|
||||||
|
|
||||||
|
while (retries > 0) {
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.db('admin').command({ ping: 1 });
|
||||||
|
await client.close();
|
||||||
|
console.log('✅ MongoDB is ready');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
retries--;
|
||||||
|
if (retries === 0) {
|
||||||
|
console.error('❌ Failed to connect to MongoDB after multiple attempts');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
console.log(`Waiting for MongoDB... (${retries} retries left)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPatchesData() {
|
||||||
|
const client = new MongoClient(getMongoUri());
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(__dirname, '../data/patches.json');
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// Check if it's line-delimited JSON or array format
|
||||||
|
let patchesData;
|
||||||
|
if (fileContent.trim().startsWith('[')) {
|
||||||
|
// Array format
|
||||||
|
patchesData = JSON.parse(fileContent);
|
||||||
|
if (!Array.isArray(patchesData)) {
|
||||||
|
throw new Error('Patches data should be an array');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Line-delimited JSON format
|
||||||
|
patchesData = fileContent.split('\n')
|
||||||
|
.filter(line => line.trim() !== '')
|
||||||
|
.map(line => {
|
||||||
|
const doc = JSON.parse(line);
|
||||||
|
return convertMongoExtendedJson(doc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert any extended JSON in array format too
|
||||||
|
if (Array.isArray(patchesData)) {
|
||||||
|
patchesData = patchesData.map(doc => convertMongoExtendedJson(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = client.db('patches');
|
||||||
|
const collection = db.collection('patches');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
await collection.deleteMany({});
|
||||||
|
|
||||||
|
// Insert new data
|
||||||
|
const result = await collection.insertMany(patchesData);
|
||||||
|
console.log(`✅ Imported ${result.insertedCount} patches`);
|
||||||
|
|
||||||
|
// Create index
|
||||||
|
await collection.createIndex({ date: -1 });
|
||||||
|
console.log('✅ Created patches index');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to import patches:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importMatchesData(patchVersion) {
|
||||||
|
const matchesFile = path.join(__dirname, '../data', `${patchVersion}_matches.json`);
|
||||||
|
const collectionName = patchVersion;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = execSync(
|
||||||
|
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, MONGO_URI: getMongoUri() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log('✅ Matches import completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to import matches:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateChampionStats() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Running match collector...');
|
||||||
|
|
||||||
|
// Set environment variables for development mode
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
USE_IMPORTED_DATA: 'true',
|
||||||
|
MONGO_URI: getMongoUri(),
|
||||||
|
MONGO_USER: 'root',
|
||||||
|
MONGO_PASS: 'password',
|
||||||
|
MONGO_HOST: 'localhost'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the match collector directly with tsx (TypeScript executor) instead of docker compose
|
||||||
|
const matchCollectorPath = path.join(__dirname, '../../match_collector/index.ts');
|
||||||
|
execSync(`npx tsx ${matchCollectorPath}`, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: env,
|
||||||
|
cwd: path.join(__dirname, '../..')
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Champion stats generated');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to generate champion stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMatchCount(patchVersion) {
|
||||||
|
const client = new MongoClient(getMongoUri());
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = client.db('matches');
|
||||||
|
const collection = db.collection(patchVersion);
|
||||||
|
const count = await collection.countDocuments();
|
||||||
|
return count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to get match count:', error);
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMongoUri() {
|
||||||
|
return process.env.MONGO_URI || 'mongodb://root:password@localhost:27017/buildpath?authSource=admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert MongoDB extended JSON format to standard MongoDB objects
|
||||||
|
* Handles $oid, $date, and other extended JSON operators
|
||||||
|
*/
|
||||||
|
function convertMongoExtendedJson(doc) {
|
||||||
|
if (!doc || typeof doc !== 'object') {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ObjectId
|
||||||
|
if (doc._id && doc._id.$oid) {
|
||||||
|
doc._id = new ObjectId(doc._id.$oid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Date
|
||||||
|
if (doc.date && doc.date.$date) {
|
||||||
|
doc.date = new Date(doc.date.$date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process nested objects
|
||||||
|
for (const key in doc) {
|
||||||
|
if (doc[key] && typeof doc[key] === 'object') {
|
||||||
|
if (Array.isArray(doc[key])) {
|
||||||
|
doc[key] = doc[key].map(item => convertMongoExtendedJson(item));
|
||||||
|
} else {
|
||||||
|
doc[key] = convertMongoExtendedJson(doc[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MongoDB ObjectId for extended JSON conversion
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
|
||||||
|
// Additional utility functions
|
||||||
|
async function checkDatabaseStatus() {
|
||||||
|
const client = new MongoClient(getMongoUri());
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const adminDb = client.db('admin');
|
||||||
|
const status = await adminDb.command({ serverStatus: 1 });
|
||||||
|
|
||||||
|
console.log('📊 Database Status:');
|
||||||
|
console.log(` - Version: ${status.version}`);
|
||||||
|
console.log(` - Uptime: ${Math.floor(status.uptime / 60)} minutes`);
|
||||||
|
console.log(` - Connections: ${status.connections.current}`);
|
||||||
|
console.log(` - Memory Usage: ${(status.mem.resident / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
|
||||||
|
// Check collections
|
||||||
|
const dbNames = await adminDb.admin().listDatabases();
|
||||||
|
console.log('📦 Databases:');
|
||||||
|
dbNames.databases.forEach(db => {
|
||||||
|
if (db.name !== 'admin' && db.name !== 'local' && db.name !== 'config') {
|
||||||
|
console.log(` - ${db.name}: ${(db.sizeOnDisk / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to get database status:', error);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command line interface
|
||||||
|
if (require.main === module) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'status':
|
||||||
|
checkDatabaseStatus().catch(console.error);
|
||||||
|
break;
|
||||||
|
case 'import-matches':
|
||||||
|
if (args[1]) {
|
||||||
|
importMatchesData(args[1]).catch(console.error);
|
||||||
|
} else {
|
||||||
|
console.error('❌ Please provide a patch version');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'generate-stats':
|
||||||
|
generateChampionStats().catch(console.error);
|
||||||
|
break;
|
||||||
|
case 'import-patches':
|
||||||
|
importPatchesData().catch(console.error);
|
||||||
|
break;
|
||||||
|
case 'match-count':
|
||||||
|
if (args[1]) {
|
||||||
|
getMatchCount(args[1]).then(count => console.log(`Match count: ${count}`)).catch(console.error);
|
||||||
|
} else {
|
||||||
|
console.error('❌ Please provide a patch version');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'latest-patch':
|
||||||
|
getLatestPatchVersion().then(patch => console.log(`Latest patch: ${patch}`)).catch(console.error);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setupDatabase().catch(error => {
|
||||||
|
console.error('❌ Setup failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
setupDatabase,
|
||||||
|
importPatchesData,
|
||||||
|
importMatchesData,
|
||||||
|
generateChampionStats,
|
||||||
|
checkDatabaseStatus,
|
||||||
|
getMatchCount,
|
||||||
|
getLatestPatchVersion
|
||||||
|
};
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
services:
|
|
||||||
mongo:
|
|
||||||
hostname: mongo
|
|
||||||
image: mongo:latest
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- mongo_data:/data/db
|
|
||||||
environment:
|
|
||||||
MONGO_INITDB_ROOT_USERNAME: root
|
|
||||||
MONGO_INITDB_ROOT_PASSWORD: password
|
|
||||||
|
|
||||||
mongo-express:
|
|
||||||
image: mongo-express
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- target: 8081
|
|
||||||
published: 8081
|
|
||||||
mode: host
|
|
||||||
environment:
|
|
||||||
ME_CONFIG_MONGODB_ADMINUSERNAME: root
|
|
||||||
ME_CONFIG_MONGODB_ADMINPASSWORD: password
|
|
||||||
ME_CONFIG_MONGODB_URL: mongodb://root:password@mongo:27017/
|
|
||||||
ME_CONFIG_BASICAUTH: "false"
|
|
||||||
|
|
||||||
patch_detector:
|
|
||||||
image: git.vhaudiquet.fr/vhaudiquet/lolstats-patch_detector:${GIT_COMMIT_HASH:-latest}
|
|
||||||
build: ./patch_detector
|
|
||||||
restart: "no"
|
|
||||||
deploy:
|
|
||||||
restart_policy:
|
|
||||||
condition: any
|
|
||||||
delay: '0'
|
|
||||||
window: 10s
|
|
||||||
environment:
|
|
||||||
MONGO_USER: root
|
|
||||||
MONGO_PASS: password
|
|
||||||
MONGO_HOST: mongo
|
|
||||||
|
|
||||||
match_collector:
|
|
||||||
image: git.vhaudiquet.fr/vhaudiquet/lolstats-match_collector:${GIT_COMMIT_HASH:-latest}
|
|
||||||
build: ./match_collector
|
|
||||||
restart: "no"
|
|
||||||
deploy:
|
|
||||||
restart_policy:
|
|
||||||
condition: any
|
|
||||||
delay: '0'
|
|
||||||
window: 20s
|
|
||||||
environment:
|
|
||||||
MONGO_USER: root
|
|
||||||
MONGO_PASS: password
|
|
||||||
MONGO_HOST: mongo
|
|
||||||
RIOT_API_KEY: ${RIOT_API_KEY}
|
|
||||||
# restarter:
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:${GIT_COMMIT_HASH:-latest}
|
|
||||||
build: ./frontend
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- target: 3000
|
|
||||||
published: 3000
|
|
||||||
mode: host
|
|
||||||
environment:
|
|
||||||
MONGO_USER: root
|
|
||||||
MONGO_PASS: password
|
|
||||||
MONGO_HOST: mongo
|
|
||||||
volumes:
|
|
||||||
mongo_data:
|
|
||||||
@@ -1,82 +1,214 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const {data: championsData} : ChampionsResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json")
|
import { debounce, isEmpty } from '~/utils/helpers';
|
||||||
const champions = championsData.value.slice(1)
|
|
||||||
.filter((champion) =>
|
|
||||||
!champion.name.includes("Doom Bot"))
|
|
||||||
.sort((a, b) => {
|
|
||||||
if(a.name < b.name) return -1;
|
|
||||||
if(a.name > b.name) return 1;
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
|
|
||||||
const {data: championsLanes} : {data: Ref<Array<ChampionData>>} = await useFetch("/api/champions")
|
// Constants
|
||||||
const lanesMap : Map<string, Array<LaneData>> = new Map()
|
const CDRAGON_CHAMPIONS_URL = CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json";
|
||||||
for(let champion of championsLanes.value) {
|
const CHAMPIONS_API_URL = "/api/champions";
|
||||||
lanesMap.set(champion.alias, champion.lanes)
|
|
||||||
|
// State
|
||||||
|
const { data: championsData, pending: loadingChampions, error: championsError } = useFetch(CDRAGON_CHAMPIONS_URL, {
|
||||||
|
key: 'champions-data',
|
||||||
|
lazy: false,
|
||||||
|
server: false // Disable server-side fetching to avoid hydration issues
|
||||||
|
});
|
||||||
|
const { data: championsLanes, pending: loadingLanes, error: lanesError } = useFetch(CHAMPIONS_API_URL, {
|
||||||
|
key: 'champions-lanes',
|
||||||
|
lazy: false,
|
||||||
|
server: false // Disable server-side fetching to avoid hydration issues
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data processing
|
||||||
|
const champions = computed(() => {
|
||||||
|
if (!championsData.value || !Array.isArray(championsData.value)) return [];
|
||||||
|
|
||||||
|
return championsData.value.slice(1)
|
||||||
|
.filter((champion: any) => !champion.name.includes("Doom Bot"))
|
||||||
|
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
const lanesMap = computed(() => {
|
||||||
|
const map = new Map<string, LaneData[]>();
|
||||||
|
if (championsLanes.value) {
|
||||||
|
for (const champion of championsLanes.value as ChampionData[]) {
|
||||||
|
map.set(champion.alias.toLowerCase(), champion.lanes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const filteredChampions = ref<ChampionSummary[]>([]);
|
||||||
|
const searchText = ref("");
|
||||||
|
const searchBar = useTemplateRef("searchBar");
|
||||||
|
|
||||||
|
// Lane filtering
|
||||||
|
function filterToLane(filter: number): string {
|
||||||
|
const laneMap = ["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"];
|
||||||
|
return laneMap[filter] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredChampions = ref(champions)
|
function filterChampionsByLane(laneFilter: number): void {
|
||||||
|
if (laneFilter === -1) {
|
||||||
|
filteredChampions.value = [...champions.value];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const searchBar = useTemplateRef("searchBar")
|
const laneName = filterToLane(laneFilter);
|
||||||
watch(searchBar, (newS, oldS) => {searchBar.value?.focus()})
|
filteredChampions.value = champions.value.filter((champion: any) => {
|
||||||
const searchText = ref("")
|
const championLanes = lanesMap.value.get(champion.alias.toLowerCase());
|
||||||
watch(searchText, (newT, oldT) => {
|
if (!championLanes) return false;
|
||||||
filteredChampions.value = champions.filter((champion) => champion.name.toLowerCase().includes(searchText.value.toLowerCase()))
|
|
||||||
})
|
|
||||||
|
|
||||||
function filterToLane(filter: number) {
|
return championLanes.some(lane => lane.data === laneName);
|
||||||
switch(filter) {
|
});
|
||||||
case 0:
|
}
|
||||||
return "TOP";
|
|
||||||
case 1:
|
// Search functionality
|
||||||
return "JUNGLE";
|
const debouncedSearch = debounce((searchTerm: string) => {
|
||||||
case 2:
|
if (isEmpty(searchTerm)) {
|
||||||
return "MIDDLE";
|
filteredChampions.value = [...champions.value];
|
||||||
case 3:
|
} else {
|
||||||
return "BOTTOM";
|
filteredChampions.value = champions.value.filter((champion: any) =>
|
||||||
case 4:
|
champion.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
return "UTILITY";
|
);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(searchBar, (newS, oldS) => {
|
||||||
|
searchBar.value?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(searchText, (newTerm) => {
|
||||||
|
debouncedSearch(newTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
async function navigateToChampion(championAlias: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigateTo(`/champion/${championAlias.toLowerCase()}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Navigation error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLaneFilterChange(newValue: number) {
|
// Initialize filtered champions
|
||||||
if(newValue != -1) {
|
onMounted(() => {
|
||||||
filteredChampions.value = champions.filter((champion) => {
|
filteredChampions.value = [...champions.value];
|
||||||
const lanes : Array<LaneData> | undefined = lanesMap.get(champion.alias.toLowerCase())
|
});
|
||||||
if(lanes == undefined) return false;
|
|
||||||
|
|
||||||
return lanes.reduce((acc : boolean, current : {data:string, count:number}) =>
|
|
||||||
acc || (current.data == filterToLane(newValue)), false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
filteredChampions.value = champions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
await navigateTo("/champion/" + filteredChampions.value[0].alias.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
const hasErrors = computed(() => championsError.value || lanesError.value);
|
||||||
|
const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="search-lanefilter-container">
|
<!-- Loading state -->
|
||||||
<LaneFilter id="cs-lanefilter" @filter-change="(value : number) => onLaneFilterChange(value)"/>
|
<div v-if="isLoading" class="loading-state">
|
||||||
<input @keyup.enter="submit" v-model="searchText" ref="searchBar" class="search-bar" type="text" placeholder="Search a champion"/>
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Loading champions...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="champion-container">
|
|
||||||
<NuxtLink style="width: fit-content; height: fit-content;" v-for="champion in filteredChampions" :to="'/champion/' + champion.alias.toLowerCase()">
|
<!-- Error state -->
|
||||||
|
<div v-else-if="hasErrors" class="error-state">
|
||||||
|
<p>Failed to load champion data. Please refresh the page.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="search-lanefilter-container">
|
||||||
|
<LaneFilter
|
||||||
|
id="cs-lanefilter"
|
||||||
|
@filter-change="filterChampionsByLane"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
@keyup.enter="() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)"
|
||||||
|
v-model="searchText"
|
||||||
|
ref="searchBar"
|
||||||
|
class="search-bar"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search a champion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="filteredChampions.length === 0" class="empty-state">
|
||||||
|
<p>No champions found. Try a different search or filter.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="champion-container">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="champion in filteredChampions"
|
||||||
|
:key="champion.id"
|
||||||
|
:to="'/champion/' + champion.alias.toLowerCase()"
|
||||||
|
style="width: fit-content; height: fit-content;"
|
||||||
|
>
|
||||||
<div class="cs-champion-img-container">
|
<div class="cs-champion-img-container">
|
||||||
<NuxtImg format="webp" class="cs-champion-img" :src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)" :alt="champion.name"/>
|
<NuxtImg
|
||||||
|
format="webp"
|
||||||
|
class="cs-champion-img"
|
||||||
|
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
|
||||||
|
:alt="champion.name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Loading and error states */
|
||||||
|
.loading-state, .error-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--color-surface);
|
||||||
|
border-top: 4px solid var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state p {
|
||||||
|
margin: 0;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
@@ -115,11 +247,6 @@ async function submit() {
|
|||||||
grid-gap: 10px;
|
grid-gap: 10px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
/* overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
scrollbar-color: var(--color-on-surface) var(--color-surface-darker);
|
|
||||||
scrollbar-width: thin; */
|
|
||||||
|
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|||||||
@@ -1,39 +1,105 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { isEmpty, deepClone } from '~/utils/helpers';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
builds: Builds
|
builds: Builds;
|
||||||
}>()
|
loading?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const {data : items} : ItemResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/items.json")
|
// Constants
|
||||||
const itemMap = reactive(new Map())
|
const ITEMS_API_URL = CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/items.json";
|
||||||
for(let item of items.value) {
|
|
||||||
itemMap.set(item.id, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const builds = ref(JSON.parse(JSON.stringify(props.builds)))
|
// State
|
||||||
watch(() => props.builds, () => {
|
const { data: items, pending: loadingItems, error: itemsError } = await useFetch(ITEMS_API_URL);
|
||||||
builds.value = JSON.parse(JSON.stringify(props.builds))
|
const itemMap = ref<Map<number, any>>(new Map());
|
||||||
trimBuilds(builds.value)
|
|
||||||
trimLateGameItems(builds.value)
|
|
||||||
})
|
|
||||||
trimBuilds(builds.value)
|
|
||||||
trimLateGameItems(builds.value)
|
|
||||||
|
|
||||||
function trimBuilds(builds : Builds) {
|
// Initialize item map
|
||||||
builds.tree.children.splice(1, builds.tree.children.length - 1)
|
watch(items, (newItems) => {
|
||||||
if(builds.tree.children[0] != null && builds.tree.children[0] != undefined)
|
try {
|
||||||
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
|
const itemsData = newItems || [];
|
||||||
}
|
if (Array.isArray(itemsData)) {
|
||||||
function trimLateGameItems(builds: Builds) {
|
const map = new Map<number, any>();
|
||||||
function trimLateGameItemsFromTree(tree: ItemTree) {
|
for (const item of itemsData) {
|
||||||
const foundIndex = builds.lateGame.findIndex((x) => x.data === tree.data)
|
if (item?.id) {
|
||||||
if(foundIndex != -1) builds.lateGame.splice(foundIndex, 1)
|
map.set(item.id, item);
|
||||||
for(let children of tree.children) {
|
|
||||||
trimLateGameItemsFromTree(children)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trimLateGameItemsFromTree(builds.tree)
|
itemMap.value = map;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing item map:', error);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Builds management
|
||||||
|
const builds = ref<Builds>(deepClone(props.builds));
|
||||||
|
|
||||||
|
watch(() => props.builds, (newBuilds) => {
|
||||||
|
builds.value = deepClone(newBuilds);
|
||||||
|
trimBuilds(builds.value);
|
||||||
|
trimLateGameItems(builds.value);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Initialize with trimmed builds
|
||||||
|
onMounted(() => {
|
||||||
|
trimBuilds(builds.value);
|
||||||
|
trimLateGameItems(builds.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim builds tree to show only primary build paths
|
||||||
|
*/
|
||||||
|
function trimBuilds(builds: Builds): void {
|
||||||
|
if (!builds?.tree?.children) return;
|
||||||
|
|
||||||
|
// Keep only the first child (primary build path)
|
||||||
|
builds.tree.children.splice(1, builds.tree.children.length - 1);
|
||||||
|
|
||||||
|
// For the primary path, keep only the first child of the first child
|
||||||
|
if (builds.tree.children[0]?.children) {
|
||||||
|
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove items from lateGame that are already in the build tree
|
||||||
|
*/
|
||||||
|
function trimLateGameItems(builds: Builds): void {
|
||||||
|
if (!builds?.tree || isEmpty(builds.lateGame)) return;
|
||||||
|
|
||||||
|
function trimLateGameItemsFromTree(tree: ItemTree): void {
|
||||||
|
const foundIndex = builds.lateGame.findIndex((x) => x.data === tree.data);
|
||||||
|
if (foundIndex !== -1) {
|
||||||
|
builds.lateGame.splice(foundIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of tree.children || []) {
|
||||||
|
trimLateGameItemsFromTree(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimLateGameItemsFromTree(builds.tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get item data safely
|
||||||
|
*/
|
||||||
|
function getItemData(itemId: number): any {
|
||||||
|
return itemMap.value.get(itemId) || { iconPath: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate percentage for item display
|
||||||
|
*/
|
||||||
|
function getItemPercentage(item: { count: number }, total: number): string {
|
||||||
|
if (total <= 0) return '0%';
|
||||||
|
return ((item.count / total) * 100).toFixed(0) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error and loading states
|
||||||
|
const hasError = computed(() => itemsError.value || props.error);
|
||||||
|
const isLoading = computed(() => loadingItems.value || props.loading);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@nuxt/image": "^1.10.0",
|
"@nuxt/image": "^1.10.0",
|
||||||
"@nuxtjs/seo": "^3.0.3",
|
"@nuxtjs/seo": "^3.0.3",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"mongodb": "^6.10.0",
|
"mongodb": "^6.10.0",
|
||||||
"nuxt": "^3.17.5",
|
"nuxt": "^3.17.5",
|
||||||
"nuxt-umami": "^3.2.1",
|
"nuxt-umami": "^3.2.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@nuxt/image": "^1.10.0",
|
"@nuxt/image": "^1.10.0",
|
||||||
"@nuxtjs/seo": "^3.0.3",
|
"@nuxtjs/seo": "^3.0.3",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"mongodb": "^6.10.0",
|
"mongodb": "^6.10.0",
|
||||||
"nuxt": "^3.17.5",
|
"nuxt": "^3.17.5",
|
||||||
"nuxt-umami": "^3.2.1",
|
"nuxt-umami": "^3.2.1",
|
||||||
|
|||||||
@@ -1,43 +1,73 @@
|
|||||||
declare global {
|
declare global {
|
||||||
type ItemTree = {
|
/**
|
||||||
count: number
|
* Represents an item in the build tree
|
||||||
data: number
|
*/
|
||||||
children: Array<ItemTree>
|
interface ItemTree {
|
||||||
}
|
count: number;
|
||||||
type Builds = {
|
data: number;
|
||||||
start: Array<{count: number, data: number}>
|
children: ItemTree[];
|
||||||
tree: ItemTree
|
|
||||||
bootsFirst: number
|
|
||||||
boots: Array<{count: number, data: number}>
|
|
||||||
lateGame: Array<{count: number, data: number}>
|
|
||||||
suppItems?: Array<{count: number, data: number}>
|
|
||||||
}
|
|
||||||
type Rune = {
|
|
||||||
count: number
|
|
||||||
primaryStyle: number
|
|
||||||
secondaryStyle: number
|
|
||||||
selections: Array<number>
|
|
||||||
pickrate: number
|
|
||||||
}
|
|
||||||
type LaneData = {
|
|
||||||
data: string
|
|
||||||
count: number
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
runes?: Array<Rune>
|
|
||||||
builds?: Builds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChampionData = {
|
/**
|
||||||
id: number
|
* Represents champion build information
|
||||||
name: string
|
*/
|
||||||
alias: string
|
interface Builds {
|
||||||
gameCount: number
|
start: Array<{count: number, data: number}>;
|
||||||
winrate: number
|
tree: ItemTree;
|
||||||
pickrate: number
|
bootsFirst: number;
|
||||||
lanes: Array<LaneData>
|
boots: Array<{count: number, data: number}>;
|
||||||
|
lateGame: Array<{count: number, data: number}>;
|
||||||
|
suppItems?: Array<{count: number, data: number}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a rune configuration
|
||||||
|
*/
|
||||||
|
interface Rune {
|
||||||
|
count: number;
|
||||||
|
primaryStyle: number;
|
||||||
|
secondaryStyle: number;
|
||||||
|
selections: number[];
|
||||||
|
pickrate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents lane-specific champion data
|
||||||
|
*/
|
||||||
|
interface LaneData {
|
||||||
|
data: string;
|
||||||
|
count: number;
|
||||||
|
winningMatches: number;
|
||||||
|
losingMatches: number;
|
||||||
|
winrate: number;
|
||||||
|
pickrate: number;
|
||||||
|
runes?: Rune[];
|
||||||
|
builds?: Builds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents complete champion data
|
||||||
|
*/
|
||||||
|
interface ChampionData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
gameCount: number;
|
||||||
|
winrate: number;
|
||||||
|
pickrate: number;
|
||||||
|
lanes: LaneData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champion summary from CDragon
|
||||||
|
*/
|
||||||
|
interface ChampionSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
squarePortraitPath: string;
|
||||||
|
// Add other relevant fields as needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
164
frontend/utils/helpers.ts
Normal file
164
frontend/utils/helpers.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Frontend utility functions for BuildPath application
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function to limit how often a function can be called
|
||||||
|
* @param func Function to debounce
|
||||||
|
* @param wait Time in milliseconds to wait before calling the function
|
||||||
|
* @returns Debounced function
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return function(...args: Parameters<T>): void {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe JSON parsing with error handling
|
||||||
|
* @param data Data to parse
|
||||||
|
* @param defaultValue Default value to return if parsing fails
|
||||||
|
* @returns Parsed JSON or default value
|
||||||
|
*/
|
||||||
|
export function safeJsonParse<T>(data: string, defaultValue: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('JSON parse error:', error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number as percentage with specified decimal places
|
||||||
|
* @param value Number to format
|
||||||
|
* @param decimals Number of decimal places
|
||||||
|
* @returns Formatted percentage string
|
||||||
|
*/
|
||||||
|
export function formatPercentage(value: number, decimals: number = 0): string {
|
||||||
|
return (value * 100).toFixed(decimals) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize first letter of string
|
||||||
|
* @param str String to capitalize
|
||||||
|
* @returns Capitalized string
|
||||||
|
*/
|
||||||
|
export function capitalize(str: string): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert lane position to readable name
|
||||||
|
* @param position Lane position string
|
||||||
|
* @returns Readable lane name
|
||||||
|
*/
|
||||||
|
export function getLaneName(position: string): string {
|
||||||
|
const laneMap: Record<string, string> = {
|
||||||
|
'top': 'Top',
|
||||||
|
'jungle': 'Jungle',
|
||||||
|
'middle': 'Middle',
|
||||||
|
'bottom': 'Bottom',
|
||||||
|
'utility': 'Support'
|
||||||
|
};
|
||||||
|
return laneMap[position.toLowerCase()] || position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate champion image URL
|
||||||
|
* @param championAlias Champion alias
|
||||||
|
* @returns Full image URL
|
||||||
|
*/
|
||||||
|
export function getChampionImageUrl(championAlias: string): string {
|
||||||
|
return `/img/champions/${championAlias.toLowerCase()}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate item image URL
|
||||||
|
* @param itemId Item ID
|
||||||
|
* @returns Full item image URL
|
||||||
|
*/
|
||||||
|
export function getItemImageUrl(itemId: number): string {
|
||||||
|
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/items/${itemId}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate rune image URL
|
||||||
|
* @param runeId Rune ID
|
||||||
|
* @returns Full rune image URL
|
||||||
|
*/
|
||||||
|
export function getRuneImageUrl(runeId: number): string {
|
||||||
|
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/perks/${runeId}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format large numbers with abbreviations (K, M)
|
||||||
|
* @param num Number to format
|
||||||
|
* @returns Formatted string
|
||||||
|
*/
|
||||||
|
export function formatLargeNumber(num: number): string {
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return (num / 1000000).toFixed(1) + 'M';
|
||||||
|
} else if (num >= 1000) {
|
||||||
|
return (num / 1000).toFixed(1) + 'K';
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep clone an object
|
||||||
|
* @param obj Object to clone
|
||||||
|
* @returns Cloned object
|
||||||
|
*/
|
||||||
|
export function deepClone<T>(obj: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if value is empty (null, undefined, empty string, empty array, empty object)
|
||||||
|
* @param value Value to check
|
||||||
|
* @returns True if value is empty
|
||||||
|
*/
|
||||||
|
export function isEmpty(value: any): boolean {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return true;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return true;
|
||||||
|
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get winrate color based on value
|
||||||
|
* @param winrate Winrate value (0-1)
|
||||||
|
* @returns CSS color class
|
||||||
|
*/
|
||||||
|
export function getWinrateColor(winrate: number): string {
|
||||||
|
if (winrate > 0.55) return 'text-green-500';
|
||||||
|
if (winrate < 0.45) return 'text-red-500';
|
||||||
|
return 'text-yellow-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in milliseconds to readable string
|
||||||
|
* @param ms Duration in milliseconds
|
||||||
|
* @returns Formatted duration string
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,7 +219,7 @@ async function handleMatchList(client: MongoClient, patch: string, champions: Ma
|
|||||||
|
|
||||||
let currentMatch = 0;
|
let currentMatch = 0;
|
||||||
for await (let match of allMatches) {
|
for await (let match of allMatches) {
|
||||||
console.log("Computing champion stats, game entry " + currentMatch + "/" + totalMatches + " ...")
|
process.stdout.write("\rComputing champion stats, game entry " + currentMatch + "/" + totalMatches + " ... ")
|
||||||
currentMatch += 1;
|
currentMatch += 1;
|
||||||
handleMatch(match, champions)
|
handleMatch(match, champions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import champion_stat from "./champion_stat"
|
|||||||
main()
|
main()
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Check if we're in development mode with pre-loaded data
|
||||||
|
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
|
||||||
|
console.log("MatchCollector - Development mode with pre-loaded data");
|
||||||
|
await runWithPreloadedData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original production mode
|
||||||
console.log("MatchCollector - Hello !");
|
console.log("MatchCollector - Hello !");
|
||||||
const client = await connectToDatabase();
|
const client = await connectToDatabase();
|
||||||
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client);
|
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client);
|
||||||
@@ -152,3 +160,48 @@ async function saveMatch(client, match, latestPatch) {
|
|||||||
const matches = database.collection(latestPatch)
|
const matches = database.collection(latestPatch)
|
||||||
await matches.insertOne(match)
|
await matches.insertOne(match)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development mode function that generates stats from pre-loaded data
|
||||||
|
*/
|
||||||
|
async function runWithPreloadedData() {
|
||||||
|
console.log("Using pre-loaded match data for development");
|
||||||
|
|
||||||
|
const client = await connectToDatabase();
|
||||||
|
try {
|
||||||
|
const [latestPatch] = await fetchLatestPatchDate(client);
|
||||||
|
console.log(`Latest patch: ${latestPatch}`);
|
||||||
|
|
||||||
|
// Check if we have matches for this patch
|
||||||
|
const matchesDb = client.db("matches");
|
||||||
|
const collections = await matchesDb.listCollections().toArray();
|
||||||
|
const patchCollections = collections
|
||||||
|
.map(c => c.name)
|
||||||
|
.filter(name => name === latestPatch);
|
||||||
|
|
||||||
|
if (patchCollections.length === 0) {
|
||||||
|
console.error(`❌ No match data found for patch ${latestPatch}`);
|
||||||
|
console.log("💡 Please run the data import script first:");
|
||||||
|
console.log(" node dev/scripts/setup-db.js");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${patchCollections.length} match collection(s)`);
|
||||||
|
|
||||||
|
// Generate stats for each patch with data
|
||||||
|
for (const patch of patchCollections) {
|
||||||
|
console.log(`Generating stats for patch ${patch}...`);
|
||||||
|
await champion_stat.makeChampionsStats(client, patch);
|
||||||
|
console.log(`Stats generated for patch ${patch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎉 All stats generated successfully!");
|
||||||
|
console.log("🚀 Your development database is ready for frontend testing!");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error in development mode:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user