frontend-v2: Many changes to frontend (and backend) to unify build and have build variants #11

Merged
vhaudiquet merged 13 commits from frontend-v2 into main 2026-03-06 23:41:26 +00:00
40 changed files with 2587 additions and 814 deletions

View File

@@ -9,7 +9,8 @@
"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"
"status": "node scripts/setup-db.js status",
"fetch-cdragon": "node scripts/fetch-cdragon.js"
},
"dependencies": {
"mongodb": "^6.10.0"

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
import { spawn } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Cache directory - use dev/cdragon by default
const cacheDir = process.env.CDRAGON_CACHE_DIR || path.join(__dirname, '..', 'data', 'cdragon');
// Dev MongoDB credentials (matching docker-compose.yml defaults)
const mongoUser = process.env.MONGO_USER || 'root';
const mongoPass = process.env.MONGO_PASS || 'password';
const mongoHost = process.env.MONGO_HOST || 'localhost:27017';
// Run patch_detector with the cache directory and dev MongoDB credentials
const patchDetector = spawn('npx', ['tsx', '../patch_detector/index.ts'], {
cwd: path.join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'development',
CDRAGON_CACHE_DIR: cacheDir,
MONGO_USER: mongoUser,
MONGO_PASS: mongoPass,
MONGO_HOST: mongoHost
},
stdio: 'inherit'
});
patchDetector.on('close', (code) => {
process.exit(code || 0);
});

View File

@@ -23,7 +23,7 @@ async function setupDatabase() {
const patchFile = path.join(dataDir, "patches.json");
if(!fs.existsSync(dataDir) || !fs.existsSync(patchFile)) {
fs.mkdirSync(dataDir, { recursive: true });
console.log('📥 No data files found. Downloading latest snapshot...');
console.log('🚫 No data files found. Downloading latest snapshot...');
await downloadAndExtractSnapshot();
}
@@ -90,7 +90,11 @@ async function setupDatabase() {
console.log('✅ Skipping matches import - sufficient data already present');
}
// 7. Run match collector to generate stats
// 7. Fetch CDragon data for the current patch
console.log('🎮 Fetching CDragon data...');
await fetchCDragonData();
// 8. Run match collector to generate stats
console.log('📊 Generating champion stats...');
await generateChampionStats();
@@ -322,6 +326,24 @@ async function generateChampionStats() {
}
}
async function fetchCDragonData() {
try {
console.log('🔄 Running CDragon fetcher...');
// Run the fetch-cdragon script
const fetchCDragonPath = path.join(__dirname, 'fetch-cdragon.js');
execSync(`node ${fetchCDragonPath}`, {
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
console.log('✅ CDragon data fetched');
} catch (error) {
console.error('❌ Failed to fetch CDragon data:', error);
throw error;
}
}
async function getMatchCount(patchVersion) {
const client = new MongoClient(getMongoUri());
await client.connect();

View File

@@ -4,6 +4,7 @@
--color-surface: #312e2c;
--color-on-surface: #b7b8e1;
--color-surface-darker: #1f1d1c;
--color-gold: #ffd700;
}
/* Font setting */

View File

@@ -2,8 +2,6 @@
import { debounce, isEmpty } from '~/utils/helpers'
// Constants
const CDRAGON_CHAMPIONS_URL =
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
const CHAMPIONS_API_URL = '/api/champions'
// State
@@ -11,7 +9,7 @@ const {
data: championsData,
pending: loadingChampions,
error: championsError
} = useFetch(CDRAGON_CHAMPIONS_URL, {
} = useFetch('/api/cdragon/champion-summary', {
key: 'champions-data',
lazy: false,
server: false // Disable server-side fetching to avoid hydration issues

View File

@@ -41,8 +41,8 @@ const championDescription = computed(() => championData.value?.title || '')
<div style="display: flex; width: fit-content">
<div class="champion-title-img-container">
<NuxtImg
width="160"
height="160"
width="100"
height="100"
class="champion-title-img"
:src="
CDRAGON_BASE +
@@ -54,13 +54,15 @@ const championDescription = computed(() => championData.value?.title || '')
</div>
<div id="ct-info-container">
<h1>{{ championName }}</h1>
<h3 id="ct-desc">{{ championDescription }}</h3>
<h1 style="font-size: 1.5rem">{{ championName }}</h1>
<h3 id="ct-desc" style="font-size: 1rem">{{ championDescription }}</h3>
<div id="ct-basic-stat-container">
<h2 class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 class="ct-basic-stat ct-basic-stat-margin">{{ pickrate }}% pick.</h2>
<h2 class="ct-basic-stat">{{ gameCount }} games</h2>
<h2 style="font-size: 1.2rem" class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 style="font-size: 1.2rem" class="ct-basic-stat ct-basic-stat-margin">
{{ pickrate }}% pick.
</h2>
<h2 style="font-size: 1.2rem" class="ct-basic-stat">{{ gameCount }} games</h2>
</div>
</div>
</div>
@@ -68,15 +70,15 @@ const championDescription = computed(() => championData.value?.title || '')
<style>
.champion-title-img-container {
width: 160px;
height: 160px;
width: 100px;
height: 100px;
overflow: hidden;
border: 1px solid var(--color-on-surface);
}
.champion-title-img {
width: 160px;
height: 160px;
width: 100px;
height: 100px;
transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none;
@@ -93,7 +95,7 @@ const championDescription = computed(() => championData.value?.title || '')
margin-top: 5px;
}
#ct-basic-stat-container {
margin-top: 30px;
margin-top: 16px;
display: flex;
}

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
const { summonerSpellMap } = useSummonerSpellMap()
defineProps<{
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
}>()
</script>
<template>
<div class="summoner-spells-section">
<div class="summoner-spells-row">
<div v-for="(spell, i) in summonerSpells" :key="i" class="summoner-spell-item">
<NuxtImg
v-if="summonerSpellMap.get(spell.id)"
class="summoner-spell-img"
:src="CDRAGON_BASE + mapPath(summonerSpellMap.get(spell.id)!.iconPath)"
/>
<div v-else class="summoner-spell-placeholder" />
<span class="spell-pickrate">{{ (spell.pickrate * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
</template>
<style scoped>
.summoner-spells-section {
display: flex;
flex-direction: row;
}
.summoner-spells-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.summoner-spell-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.summoner-spell-img {
width: 36px;
height: 36px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.summoner-spell-placeholder {
width: 36px;
height: 36px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.spell-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.7;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.summoner-spell-img,
.summoner-spell-placeholder {
width: 32px;
height: 32px;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
const props = defineProps<{
keystoneId: number
itemId: number
keystore: Map<number, Perk>
itemMap: Map<number, Item>
pickrate: number
selected: boolean
index: number
}>()
const emit = defineEmits<{
select: [index: number]
}>()
</script>
<template>
<div class="build-variant-selector">
<div :class="['build-variant-card', { selected }]" @click="emit('select', index)">
<div class="variant-content">
<!-- Keystone -->
<NuxtImg
v-if="keystoneId && props.keystore.get(keystoneId)"
class="variant-keystone"
:src="CDRAGON_BASE + mapPath(props.keystore.get(keystoneId)!.iconPath)"
/>
<!-- First core item -->
<NuxtImg
v-if="itemMap.get(itemId)"
class="variant-item"
:src="CDRAGON_BASE + mapPath(itemMap.get(itemId)!.iconPath)"
/>
<div v-else class="variant-item-placeholder" />
</div>
<span class="variant-pickrate">{{ (pickrate * 100).toFixed(1) }}%</span>
</div>
</div>
</template>
<style scoped>
.build-variant-selector {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 20px;
padding: 0 12px;
overflow-x: auto;
scrollbar-width: none;
}
.build-variant-selector::-webkit-scrollbar {
display: none;
}
.build-variant-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 14px;
background: var(--color-surface-darker);
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 90px;
}
.build-variant-card:hover {
border-color: var(--color-on-surface);
}
.build-variant-card.selected {
border-color: #4a9eff;
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
}
.variant-content {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.variant-keystone {
width: 28px;
height: 28px;
border-radius: 50%;
}
.variant-item {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid rgba(183, 184, 225, 0.3);
}
.variant-item-placeholder {
width: 28px;
height: 28px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid rgba(183, 184, 225, 0.3);
}
.variant-pickrate {
font-size: 0.75rem;
color: var(--color-on-surface);
opacity: 0.8;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.build-variant-card {
min-width: 70px;
padding: 6px 10px;
}
.variant-keystone,
.variant-item {
width: 24px;
height: 24px;
}
.variant-item-placeholder {
width: 24px;
height: 24px;
}
.variant-pickrate {
font-size: 0.65rem;
}
}
@media only screen and (max-width: 400px) {
.build-variant-selector {
gap: 6px;
}
.build-variant-card {
min-width: 60px;
padding: 5px 8px;
}
.variant-keystone,
.variant-item {
width: 20px;
height: 20px;
}
.variant-item-placeholder {
width: 20px;
height: 20px;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface RuneBuild {
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}
const props = defineProps<{
runes: Array<RuneBuild>
perks: Map<number, Perk>
perkStyles: Map<number, PerkStyle>
}>()
const selectedIndex = ref(0)
const emit = defineEmits<{
select: [index: number]
}>()
function select(index: number) {
emit('select', index)
selectedIndex.value = index
}
</script>
<template>
<div class="compact-rune-selector">
<div
v-for="(rune, index) in props.runes"
:key="index"
:class="['compact-rune-option', { active: index === selectedIndex }]"
@click="select(index)"
>
<div class="compact-rune-content">
<NuxtImg
v-if="runes[index].primaryStyle"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(perkStyles.get(runes[index].primaryStyle)?.iconPath!)"
/>
<NuxtImg
v-if="perks.get(runes[index].selections[0])"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(perks.get(runes[index].selections[0])!.iconPath)"
/>
<NuxtImg
v-if="runes[index].secondaryStyle"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(perkStyles.get(runes[index].secondaryStyle)?.iconPath!)"
/>
</div>
<span class="compact-rune-pickrate">{{ (rune.pickrate * 100).toFixed(1) }}%</span>
</div>
</div>
</template>
<style scoped>
.compact-rune-selector {
display: flex;
justify-content: center;
gap: 6px;
margin-top: -45px;
overflow-x: auto;
scrollbar-width: none;
}
.compact-rune-selector::-webkit-scrollbar {
display: none;
}
.compact-rune-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: transparent;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 60px;
}
.compact-rune-option:hover {
border-color: var(--color-on-surface);
opacity: 0.8;
}
.compact-rune-option.active {
border-color: #4a9eff;
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
}
.compact-rune-content {
display: flex;
align-items: center;
gap: 3px;
margin-bottom: 3px;
}
.compact-rune-img {
width: 20px;
height: 20px;
border-radius: 3px;
}
.compact-rune-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.7;
}
.compact-rune-option.active .compact-rune-pickrate {
opacity: 0.9;
color: #4a9eff;
}
/* Mobile fix: Remove negative margin to prevent overlap with runes */
@media only screen and (max-width: 900px) {
.compact-rune-selector {
margin-top: 15px;
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
interface ItemData {
data: number
count: number
}
const props = defineProps<{
label: string
items: Array<ItemData>
itemMap: Map<number, Item>
totalCount: number
maxItems?: number
}>()
const maxItems = computed(() => props.maxItems ?? props.items.length)
</script>
<template>
<div class="item-row">
<span class="item-row-label">{{ label }}</span>
<div class="item-row-content">
<template v-for="item in items.slice(0, maxItems)" :key="item.data">
<ItemIcon
v-if="itemMap.get(item.data)"
:item="itemMap.get(item.data)!"
:show-pickrate="true"
:pickrate="item.count / totalCount"
:size="48"
class="item-cell"
/>
<div v-else class="item-placeholder" />
</template>
</div>
</div>
</template>
<style scoped>
.item-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-row-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-on-surface);
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.item-row-content {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item-placeholder {
width: 48px;
height: 48px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
</style>

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers'
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
import ItemRow from '~/components/build/ItemRow.vue'
const props = defineProps<{
builds: Builds
}>()
// State
const currentlySelectedBuild = ref(0)
// Use composables for data fetching
const { itemMap } = useItemMap()
const { perks, perkStyles } = useRuneStyles()
// Use composable for builds management
const { builds } = useBuilds(toRef(props, 'builds'))
const currentBuild = computed(() => builds.value[currentlySelectedBuild.value])
// Late game items for current build
const lateGameItems = computed(() => {
if (!currentBuild.value) return []
return getLateGameItems(currentBuild.value).slice(0, 6)
})
const currentlySelectedRunes = ref(0)
// Reset selected build when variant changes
watch(
() => currentBuild,
() => {
currentlySelectedBuild.value = 0
currentlySelectedRunes.value = 0
}
)
function selectRune(index: number): void {
currentlySelectedRunes.value = index
}
function selectBuild(index: number): void {
currentlySelectedBuild.value = index
}
</script>
<template>
<div v-if="currentBuild" class="build-viewer">
<div style="display: flex">
<BuildVariantSelector
v-for="(build, i) in builds"
:key="i"
:keystone-id="build.runeKeystone"
:item-id="build.items.children[0].data"
:keystore="perks"
:item-map="itemMap"
:pickrate="build.pickrate"
:selected="currentBuild == build"
:index="i"
@select="selectBuild"
/>
</div>
<!-- Main Build Content -->
<div class="build-content">
<!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column">
<!-- Rune Page -->
<div class="rune-section">
<h3 class="section-title">Runes</h3>
<div class="rune-page-wrapper">
<RunePage
v-if="currentBuild.runes"
:primary-style-id="currentBuild.runes[currentlySelectedRunes].primaryStyle"
:secondary-style-id="currentBuild.runes[currentlySelectedRunes].secondaryStyle"
:selection-ids="currentBuild.runes[currentlySelectedRunes].selections"
/>
</div>
<!-- Compact Rune Selector -->
<CompactRuneSelector
:runes="currentBuild.runes"
:perks="perks"
:perk-styles="perkStyles"
:selected-index="currentlySelectedBuild"
@select="selectRune"
/>
</div>
</div>
<!-- Right Column: Items -->
<div class="build-right-column">
<h3 class="section-title">Items</h3>
<!-- Start/Support + Boots Container -->
<div class="item-row-group">
<!-- Start Item (root of the tree) -->
<ItemRow
v-if="currentBuild.startItems && currentBuild.startItems.length > 0"
label="Start"
:items="currentBuild.startItems"
:item-map="itemMap"
:total-count="currentBuild.count"
/>
<!-- Support Items -->
<ItemRow
v-if="currentBuild.suppItems && currentBuild.suppItems.length > 0"
label="Support"
:items="currentBuild.suppItems"
:item-map="itemMap"
:total-count="currentBuild.count"
/>
<!-- Boots (regular or rush) -->
<ItemRow
:label="currentBuild.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="currentBuild.boots.slice(0, 2)"
:item-map="itemMap"
:total-count="currentBuild.count"
:max-items="2"
/>
</div>
<!-- Core Items Tree (children of start item) -->
<div v-if="currentBuild.items?.children?.length" class="item-row">
<span class="item-row-label">Core</span>
<ItemTree :tree="getCoreItems(currentBuild)" />
</div>
<!-- Late Game -->
<ItemRow
v-if="lateGameItems.length > 0"
label="Late Game"
:items="lateGameItems"
:item-map="itemMap"
:total-count="currentBuild.count"
:max-items="6"
/>
</div>
</div>
</div>
</template>
<style>
.build-viewer {
width: 100%;
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
/* Main Build Content */
.build-content {
display: flex;
justify-content: center;
gap: 80px;
padding: 0 24px;
width: 100%;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: #4a9eff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Left Column: Runes + Summoner Spells */
.build-left-column {
display: flex;
flex-direction: column;
gap: 40px;
}
.rune-section {
display: flex;
flex-direction: column;
}
.rune-page-wrapper {
margin-top: -45px;
transform: scale(0.7);
transform-origin: center;
}
/* Right Column: Items */
.build-right-column {
display: flex;
flex-direction: column;
gap: 20px;
}
.item-row-group {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.item-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-row-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-on-surface);
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.build-content {
flex-direction: column;
gap: 40px;
align-items: center;
}
.build-left-column {
order: 1;
align-items: center;
}
.build-right-column {
order: 2;
align-items: center;
}
.section-title {
font-size: 0.9rem;
}
.rune-section,
.summoner-spells-section {
align-items: center;
}
.rune-page-wrapper {
transform: scale(1);
transform-origin: center center;
margin-top: 0px;
}
}
</style>

View File

@@ -1,49 +0,0 @@
<script lang="ts" setup>
defineProps<{
title: string
bootsFirst?: number
sizePerc?: number
}>()
</script>
<template>
<div
:style="
sizePerc != undefined && sizePerc != null ? 'max-height: ' + sizePerc * 600 + 'px;' : ''
"
class="item-box"
>
<div
style="display: flex; flex-direction: column; justify-content: center; align-items: center"
>
<h2 class="item-box-title">{{ title }}</h2>
<h5 v-if="bootsFirst != undefined && bootsFirst != null" style="margin: auto">
({{ (bootsFirst * 100).toFixed(2) }}%)
</h5>
</div>
<slot />
</div>
</template>
<style scoped>
.item-box {
border: 1px solid var(--color-on-surface);
border-radius: 8px;
margin: 10px;
width: fit-content;
height: 600px;
}
.item-box-title {
font-variant: small-caps;
text-align: center;
margin: 10px;
}
@media only screen and (max-width: 1000px) {
.item-box {
width: 95%;
height: fit-content;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface Props {
item: Item
size?: number
showPickrate?: boolean
pickrate?: number
class?: string
}
// Expose the icon element for external use (e.g., arrow drawing)
const iconElement = ref<HTMLElement | null>(null)
defineExpose({
iconElement
})
const props = withDefaults(defineProps<Props>(), {
size: 48,
showPickrate: false,
pickrate: 0,
class: ''
})
// Tooltip state - encapsulated in this component
const tooltipState = reactive({
show: false,
item: null as Item | null,
x: 0,
y: 0
})
const handleMouseEnter = (event: MouseEvent) => {
tooltipState.item = props.item
// Calculate optimal position to keep tooltip within viewport
// Don't estimate height - position based on cursor location
const tooltipWidth = 300 // Maximum width from CSS
const padding = 10 // Minimum padding from edges
const offset = 15 // Distance from cursor
// Get viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Right edge detection: if we're in the right half, position to the left
let x = event.clientX + offset
if (event.clientX + tooltipWidth + offset > viewportWidth - padding) {
x = event.clientX - tooltipWidth - offset
// Clamp if still off-screen
if (x < padding) {
x = padding
}
}
// Bottom edge detection: if we're in the bottom half, position above
// Use a smaller offset for vertical to keep it close
let y = event.clientY + offset
if (event.clientY > viewportHeight * 0.7) {
y = event.clientY - offset - 200 // Position ~200px above
// Clamp if too high
if (y < padding) {
y = padding
}
}
// Ensure Y is within reasonable bounds
y = Math.min(y, viewportHeight - padding)
tooltipState.x = x
tooltipState.y = y
tooltipState.show = true
}
const handleMouseLeave = () => {
tooltipState.show = false
tooltipState.item = null
}
const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
</script>
<template>
<div class="item-icon-wrapper" @mouseleave="handleMouseLeave">
<div
ref="iconElement"
class="item-icon"
:class="props.class"
:style="{ width: size + 'px', height: size + 'px' }"
@mouseenter="handleMouseEnter"
>
<NuxtImg :src="itemIconPath" :alt="item.name || 'Item'" class="item-img" />
</div>
<span v-if="showPickrate" class="item-pickrate"> {{ (pickrate * 100).toFixed(0) }}% </span>
<ItemTooltip
:show="tooltipState.show"
:item="tooltipState.item"
:x="tooltipState.x"
:y="tooltipState.y"
/>
</div>
</template>
<style scoped>
.item-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
overflow: hidden;
cursor: help;
position: relative;
}
.item-img {
width: 100%;
height: 100%;
}
.item-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.6;
margin-top: 2px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,349 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface Props {
item: Item | null
show: boolean
x: number
y: number
}
const props = defineProps<Props>()
// Parse description and convert to styled HTML
const formatDescription = (description?: string) => {
if (!description) return ''
// Replace <br> and other structural tags
const html = description
.replace(/<br\s*\/?>/gi, '<br>')
.replace(/<br><br><br>/gi, '') // Remove triple breaks
.replace(/<br><br>/gi, '') // Remove double breaks
.replace(/<mainText>/gi, '')
.replace(/<\/mainText>/gi, '')
.replace(/<stats>/gi, '<div class="tooltip-stats">')
.replace(/<\/stats>/gi, '</div>')
.replace(/<passive>/gi, '<span class="tag-passive">')
.replace(/<\/passive>/gi, '</span>:')
.replace(/<active>/gi, '<span class="tag-active">')
.replace(/<\/active>/gi, '</span>')
.replace(/<keyword>/gi, '<span class="tag-keyword">')
.replace(/<\/keyword>/gi, '</span>')
.replace(/<attention>/gi, '<span class="stat-highlight">')
.replace(/<\/attention>/gi, '</span>')
.replace(/<keywordMajor>/gi, '<span class="tag-keyword-major">')
.replace(/<\/keywordMajor>/gi, '</span>')
.replace(/<keywordStealth>/gi, '<span class="tag-keyword-stealth">')
.replace(/<\/keywordStealth>/gi, '</span>')
.replace(/<status>/gi, '<span class="tag-status">')
.replace(/<\/status>/gi, '</span>')
.replace(/<speed>/gi, '<span class="tag-speed">')
.replace(/<\/speed>/gi, '</span>')
.replace(/<scaleMana>/gi, '<span class="tag-scale-mana">')
.replace(/<\/scaleMana>/gi, '</span>')
.replace(/<scaleHealth>/gi, '<span class="tag-scale-health">')
.replace(/<\/scaleHealth>/gi, '</span>')
.replace(/<scaleAP>/gi, '<span class="tag-scale-ap">')
.replace(/<\/scaleAP>/gi, '</span>')
.replace(/<scaleAD>/gi, '<span class="tag-scale-ad">')
.replace(/<\/scaleAD>/gi, '</span>')
.replace(/<scaleArmor>/gi, '<span class="tag-scale-armor">')
.replace(/<\/scaleArmor>/gi, '</span>')
.replace(/<scaleMR>/gi, '<span class="tag-scale-mr">')
.replace(/<\/scaleMR>/gi, '</span>')
.replace(/<scaleLevel>/gi, '<span class="tag-scale-level">')
.replace(/<\/scaleLevel>/gi, '</span>')
.replace(/<spellName>/gi, '<span class="tag-spellname">')
.replace(/<\/spellName>/gi, '</span>')
.replace(/<unique>/gi, '<span class="tag-unique">UNIQUE</span>')
.replace(/<\/unique>/gi, '')
.replace(/<rarityMythic>/gi, '<span class="tag-rarity-mythic">Mythic</span>')
.replace(/<\/rarityMythic>/gi, '')
.replace(/<rarityLegendary>/gi, '<span class="tag-rarity-legendary">Legendary</span>')
.replace(/<\/rarityLegendary>/gi, '')
.replace(/<rarityGeneric>/gi, '<span class="tag-rarity-generic">Epic</span>')
.replace(/<\/rarityGeneric>/gi, '')
.replace(/<rules>/gi, '<div class="tag-rules">')
.replace(/<\/rules>/gi, '</div>')
.replace(/<flavorText>/gi, '<div class="tag-flavor">')
.replace(/<\/flavorText>/gi, '</div>')
.replace(/<li>/gi, '<div class="tag-list">')
.replace(/<\/li>/gi, '</div>')
.replace(/<font color='([^']+)'>/gi, '<span style="color: $1">')
.replace(/<\/font>/gi, '</span>')
.replace(/<b>/gi, '<strong>')
.replace(/<\/b>/gi, '</strong>')
.replace(/\[@@[^@]*@@\]/g, ' ') // Remove stat placeholders
.trim()
return html
}
const formattedDescription = computed(() =>
props.item ? formatDescription(props.item.description) : ''
)
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="show && item"
class="item-tooltip"
:style="{
left: x + 'px',
top: y + 'px'
}"
@mouseenter.stop
>
<div class="tooltip-header">
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
<div class="tooltip-title">
<h3>{{ item.name || 'Unknown Item' }}</h3>
<span v-if="item.priceTotal" class="tooltip-gold"> {{ item.priceTotal }} Gold </span>
</div>
</div>
<div v-if="item.plaintext" class="tooltip-plaintext">
{{ item.plaintext }}
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="formattedDescription"
class="tooltip-description"
v-html="formattedDescription"
></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.item-tooltip {
position: fixed;
z-index: 1000;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
border-radius: 8px;
padding: 12px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.tooltip-header {
display: flex;
gap: 12px;
margin-bottom: 10px;
}
.tooltip-icon {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
flex-shrink: 0;
}
.tooltip-title {
display: flex;
flex-direction: column;
justify-content: center;
}
.tooltip-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-on-surface);
line-height: 1.2;
}
.tooltip-gold {
font-size: 0.85rem;
color: var(--color-gold);
margin-top: 4px;
}
.tooltip-plaintext {
font-size: 0.85rem;
color: var(--color-on-surface);
opacity: 0.8;
margin-bottom: 8px;
font-style: italic;
}
.tooltip-description {
font-size: 0.85rem;
color: var(--color-on-surface);
line-height: 1.5;
white-space: pre-wrap;
}
/* Stats section */
.tooltip-stats {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-on-surface-dim);
}
.tooltip-stats :deep(.stat-highlight) {
color: #ffcc00;
font-weight: 600;
}
/* Tag styles */
.tooltip-description :deep(.tag-passive) {
color: #4a9eff;
font-weight: 600;
}
.tooltip-description :deep(.tag-active) {
color: #ff6b6b;
font-weight: 600;
}
.tooltip-description :deep(.tag-keyword) {
color: #ffd700;
font-weight: 600;
}
.tooltip-description :deep(.tag-keyword-major) {
color: #ff8c00;
font-weight: 700;
font-style: italic;
}
.tooltip-description :deep(.tag-keyword-stealth) {
color: #9b59b6;
font-weight: 600;
}
.tooltip-description :deep(.tag-status) {
color: #e74c3c;
font-weight: 500;
font-style: italic;
}
.tooltip-description :deep(.tag-speed),
.tooltip-description :deep(.tag-scale-mana),
.tooltip-description :deep(.tag-scale-health),
.tooltip-description :deep(.tag-scale-ap),
.tooltip-description :deep(.tag-scale-ad),
.tooltip-description :deep(.tag-scale-armor),
.tooltip-description :deep(.tag-scale-mr),
.tooltip-description :deep(.tag-scale-level) {
color: #3498db;
font-style: italic;
font-weight: 500;
}
.tooltip-description :deep(.tag-healing),
.tooltip-description :deep(.tag-health) {
color: #2ecc71;
font-weight: 500;
}
.tooltip-description :deep(.tag-shield) {
color: #3498db;
font-weight: 500;
}
.tooltip-description :deep(.tag-magic-damage) {
color: #9b59b6;
font-weight: 500;
}
.tooltip-description :deep(.tag-physical-damage) {
color: #e67e22;
font-weight: 500;
}
.tooltip-description :deep(.tag-true-damage) {
color: #c0392b;
font-weight: 600;
}
.tooltip-description :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.1);
color: #3498db;
padding: 1px 4px;
border-radius: 3px;
font-size: 0.8rem;
font-weight: 500;
}
.tooltip-description :deep(.tag-spellname) {
color: #1abc9c;
font-weight: 600;
font-style: italic;
}
.tooltip-description :deep(.tag-unique) {
color: #f39c12;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.tooltip-description :deep(.tag-rarity-mythic) {
color: #ff5252;
font-weight: 700;
font-size: 0.85rem;
}
.tooltip-description :deep(.tag-rarity-legendary) {
color: #ff9800;
font-weight: 600;
font-size: 0.85rem;
}
.tooltip-description :deep(.tag-rarity-generic) {
color: #ffd54f;
font-weight: 500;
font-size: 0.85rem;
}
.tooltip-description :deep(.tag-rules) {
margin-top: 8px;
padding: 6px;
background: rgba(0, 0, 0, 0.1);
border-left: 2px solid var(--color-on-surface-dim);
font-size: 0.8rem;
font-style: italic;
opacity: 0.8;
}
.tooltip-description :deep(.tag-flavor) {
margin-top: 10px;
padding: 6px;
background: rgba(74, 222, 255, 0.1);
border-left: 2px solid #4a9eff;
font-size: 0.8rem;
font-style: italic;
color: rgba(255, 255, 255, 0.7);
}
.tooltip-description :deep(.tag-list) {
margin: 4px 0;
padding-left: 12px;
position: relative;
}
.tooltip-description strong {
font-weight: 600;
color: var(--color-on-surface);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -9,18 +9,19 @@ defineProps<{
const emit = defineEmits<{
mount: [end: Element]
refresh: []
parentReady: []
}>()
const { data: items } = useFetch<Array<{ id: number; iconPath: string }>>(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
{
lazy: true, // Don't block rendering
server: false // Client-side only
}
)
const { data: items, pending: itemsLoading } = useFetch<Array<Item>>('/api/cdragon/items', {
lazy: true, // Don't block rendering
server: false // Client-side only
})
// Track image loading state
const imagesLoaded = ref(false)
// Create item map reactively
const itemMap = reactive(new Map<number, { id: number; iconPath: string }>())
const itemMap = reactive(new Map<number, Item>())
watch(
items,
newItems => {
@@ -34,19 +35,85 @@ watch(
{ immediate: true }
)
function getItemIconPath(itemId: number): string {
const item = itemMap.get(itemId)
return item ? CDRAGON_BASE + mapPath(item.iconPath) : ''
const startTreeItem = useTemplateRef('start')
const arrows: Array<svgdomarrowsLinePath> = []
const pendingChildMounts: Array<Element> = []
// Get the actual icon element for arrow drawing
const startElement = computed(() => startTreeItem.value?.iconElement ?? null)
// Function to wait for an image to load
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
return new Promise(resolve => {
if (imgElement.complete) {
requestAnimationFrame(() => resolve())
return
}
imgElement.addEventListener(
'load',
() => {
requestAnimationFrame(() => resolve())
},
{ once: true }
)
imgElement.addEventListener(
'error',
() => {
requestAnimationFrame(() => resolve())
},
{ once: true }
)
})
}
const start: Ref<Element | null> = useTemplateRef('start')
const arrows: Array<svgdomarrowsLinePath> = []
onMounted(async () => {
// Wait for next tick to ensure DOM is ready
await nextTick()
onMounted(() => {
// Only refresh arrows and emit if start element is available
if (start.value) {
refreshArrows()
emit('mount', start.value)
// Wait for items to be loaded
await new Promise<void>(resolve => {
if (!itemsLoading.value) {
resolve()
} else {
const unwatch = watch(itemsLoading, loading => {
if (!loading) {
unwatch()
resolve()
}
})
}
})
if (startElement.value) {
// Wait for the ItemIcon to load its image
const imgElement = startElement.value.querySelector('img')
if (imgElement) {
await waitForImageLoad(imgElement as HTMLImageElement)
}
// Now that image is loaded and DOM is ready, draw arrows
imagesLoaded.value = true
// Notify children that parent is ready
emit('parentReady')
// Draw any pending arrows from children that mounted before we were ready
if (pendingChildMounts.length > 0) {
await nextTick()
for (const childEnd of pendingChildMounts) {
drawArrow(startElement.value!, childEnd)
}
pendingChildMounts.length = 0
}
// Use multiple requestAnimationFrame to ensure rendering is complete
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
emit('mount', startElement.value!)
})
})
}
})
@@ -57,10 +124,17 @@ onBeforeUpdate(() => {
arrows.splice(0, arrows.length)
})
onUpdated(() => {
if (start.value) {
refreshArrows()
emit('mount', start.value)
onUpdated(async () => {
await nextTick()
if (startElement.value && imagesLoaded.value) {
// Redraw arrows after DOM update
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
emit('mount', startElement.value!)
})
})
}
})
@@ -71,7 +145,6 @@ onUnmounted(() => {
})
function drawArrow(start: Element, end: Element) {
// console.log("drawArrow(", start, ", ", end, ")")
if (start == null || end == null) return
const arrow = new svgdomarrowsLinePath({
@@ -89,16 +162,21 @@ function drawArrow(start: Element, end: Element) {
left: 0
}
},
style: 'stroke:var(--color-on-surface);stroke-width:3;fill:transparent;',
style: 'stroke:var(--color-on-surface);stroke-width:2;fill:transparent;pointer-events:none;',
appendTo: document.body
})
arrows.push(arrow)
}
function refreshArrows() {
for (const arrow of arrows) {
arrow.redraw()
}
// Double requestAnimationFrame to ensure layout is complete
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const arrow of arrows) {
arrow.redraw()
}
})
})
}
// Redraw arrows on window resize
@@ -110,12 +188,36 @@ addEventListener('scroll', _ => {
})
function handleSubtreeMount(end: Element) {
if (start.value) {
drawArrow(start.value, end)
refreshArrows()
if (startElement.value) {
if (imagesLoaded.value) {
// Parent is ready, draw arrow immediately
requestAnimationFrame(() => {
requestAnimationFrame(() => {
drawArrow(startElement.value!, end)
refreshArrows()
})
})
} else {
// Parent not ready yet, store for later
pendingChildMounts.push(end)
}
}
emit('refresh')
}
function handleParentReady() {
// Parent became ready, redraw all our arrows
if (startElement.value && imagesLoaded.value) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
})
})
}
// Propagate to children
emit('parentReady')
}
function handleRefresh() {
refreshArrows()
emit('refresh')
@@ -123,37 +225,64 @@ function handleRefresh() {
</script>
<template>
<div style="display: flex; align-items: center">
<div
v-if="tree.data != undefined && tree.data != null"
style="width: fit-content; height: fit-content"
>
<img
<div class="item-tree-container">
<div v-if="tree.data != undefined && tree.data != null" class="item-tree-node">
<ItemIcon
v-if="itemMap.get(tree.data)"
ref="start"
class="item-img"
width="64"
height="64"
:alt="tree.data.toString()"
:src="getItemIconPath(tree.data)"
:item="itemMap.get(tree.data)!"
:show-pickrate="true"
:pickrate="parentCount ? tree.count / parentCount : 0"
:size="48"
class="item-tree-img"
/>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%
</h3>
</div>
<div style="margin-left: 30px">
<div
v-for="child in tree.children"
:key="child.data"
style="width: fit-content; height: fit-content"
>
<div class="item-tree-children">
<div v-for="child in tree.children" :key="child.data" class="item-tree-child">
<ItemTree
:tree="child"
:parent-count="tree.count"
@refresh="handleRefresh"
@mount="end => handleSubtreeMount(end)"
@parent-ready="handleParentReady"
/>
</div>
</div>
</div>
</template>
<style>
.item-tree-container {
display: flex;
align-items: center;
justify-content: flex-start;
}
.item-tree-node {
display: flex;
flex-direction: column;
align-items: center;
width: fit-content;
position: relative;
z-index: 10;
}
.item-tree-children {
margin-left: 32px;
position: relative;
z-index: 1;
}
.item-tree-child {
width: fit-content;
position: relative;
}
/* Mobile responsive */
@media only screen and (max-width: 900px) {
.item-tree-children {
margin-left: 20px;
}
}
</style>

View File

@@ -1,349 +0,0 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers'
const props = defineProps<{
builds: Builds
loading?: boolean
error?: boolean
}>()
// Constants
const ITEMS_API_URL = CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
// State
const {
data: items,
pending: loadingItems,
error: itemsError
} = useFetch(ITEMS_API_URL, {
lazy: true, // Don't block rendering
server: false // Client-side only
})
const itemMap = ref<Map<number, unknown>>(new Map())
// Initialize item map
watch(
items,
newItems => {
try {
const itemsData = newItems || []
if (Array.isArray(itemsData)) {
const map = new Map<number, unknown>()
for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item)
}
}
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)
}
// Error and loading states
const _hasError = computed(() => itemsError.value || props.error)
const _isLoading = computed(() => loadingItems.value || props.loading)
</script>
<template>
<div id="iv-container">
<div>
<!-- Start items -->
<ItemBox v-if="builds.suppItems == undefined || builds.suppItems == null" title="start">
<div class="iv-items-container">
<div
v-for="item in builds.start"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Supp items -->
<ItemBox v-if="builds.suppItems != undefined && builds.suppItems != null" title="supp">
<div class="iv-items-container">
<div
v-for="item in builds.suppItems"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
</div>
<!-- Boots first : when champion rush boots -->
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :boots-first="builds.bootsFirst">
<div class="iv-items-container">
<div
v-for="item in builds.boots"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Core items -->
<ItemBox title="core">
<ItemTree style="margin: auto; width: fit-content" :tree="builds.tree" />
</ItemBox>
<!-- Boots -->
<ItemBox v-if="builds.bootsFirst <= 0.5" title="boots">
<div class="iv-items-container">
<div
v-for="item in builds.boots.slice(0, 4)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Late game items -->
<ItemBox title="late game">
<div id="iv-late-game-container">
<div class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(0, 4)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
<div v-if="builds.lateGame.length > 4" class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(4, 8)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</div>
</ItemBox>
</div>
</template>
<style>
#iv-container {
display: flex;
width: fit-content;
height: fit-content;
}
.iv-items-container {
display: flex;
flex-direction: column;
width: fit-content;
height: fit-content;
margin: auto;
}
.item-img {
border: 1px solid var(--color-on-surface);
margin: 10px;
}
#iv-late-game-container {
display: flex;
}
@media only screen and (max-width: 1000px) {
#iv-container {
flex-direction: column;
width: 100%;
}
.iv-items-container {
flex-direction: row;
}
.item-img {
width: 48px;
height: 48px;
}
#iv-late-game-container {
flex-direction: column;
}
}
</style>

View File

@@ -10,7 +10,7 @@ const emit = defineEmits<{
stateChange: [state: string, lane: number]
}>()
const state = ref('runes')
const state = ref('build')
const laneState = ref(0)
function handleStateChange(newState: string, newLane: number) {
@@ -49,16 +49,10 @@ if (route.path.startsWith('/tierlist/')) {
/>
<div class="nav-buttons">
<button
:class="['nav-button', { active: state == 'runes' && laneState == i }]"
@click="handleStateChange('runes', i)"
:class="['nav-button', { active: state == 'build' && laneState == i }]"
@click="handleStateChange('build', i)"
>
Runes
</button>
<button
:class="['nav-button', { active: state == 'items' && laneState == i }]"
@click="handleStateChange('items', i)"
>
Items
Build
</button>
<button
:class="['nav-button', { active: state == 'alternatives' && laneState == i }]"

View File

@@ -10,7 +10,7 @@ const emit = defineEmits<{
stateChange: [state: string, lane: number]
}>()
const state = ref('runes')
const state = ref('build')
const laneState = ref(0)
function handleStateChange(newState: string, newLane: number) {
@@ -39,9 +39,9 @@ if (route.path.startsWith('/tierlist/')) {
<div class="sidebar-container">
<Logo
font-size="2.6rem"
img-width="60"
style="padding-left: 15px; padding-right: 15px; margin-top: 30px"
font-size="2rem"
img-width="45"
style="padding-left: 10px; padding-right: 10px; margin-top: 20px"
/>
<div v-for="(lane, i) in championLanes" :key="i">
@@ -49,22 +49,22 @@ if (route.path.startsWith('/tierlist/')) {
style="
display: flex;
align-items: center;
margin-top: 30px;
margin-top: 20px;
padding-right: 10px;
overflow: hidden;
"
>
<h1 style="font-size: 2.4rem; padding-left: 20px">{{ championName }}</h1>
<h1 style="font-size: 1.8rem; padding-left: 15px">{{ championName }}</h1>
<NuxtImg
format="webp"
style="margin-left: 10px"
width="40"
height="40"
style="margin-left: 8px"
width="30"
height="30"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
/>
<h2
v-if="championName != null && championName != undefined && championName.length < 8"
style="margin-left: 5px; font-size: 1.8rem; font-weight: 200"
style="margin-left: 5px; font-size: 1.4rem; font-weight: 200"
>
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
</h2>
@@ -72,21 +72,12 @@ if (route.path.startsWith('/tierlist/')) {
<h2
:class="
'sidebar-link ' + (state == 'runes' && laneState == i ? 'sidebar-link-selected' : '')
'sidebar-link ' + (state == 'build' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
@click="handleStateChange('runes', i)"
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
@click="handleStateChange('build', i)"
>
Runes
</h2>
<h2
:class="
'sidebar-link ' + (state == 'items' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
@click="handleStateChange('items', i)"
>
Items
Build
</h2>
<h2
@@ -94,7 +85,7 @@ if (route.path.startsWith('/tierlist/')) {
'sidebar-link ' +
(state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
@click="handleStateChange('alternatives', i)"
>
Alternatives
@@ -103,15 +94,15 @@ if (route.path.startsWith('/tierlist/')) {
:class="
'sidebar-link ' + (state == 'matchups' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
@click="handleStateChange('matchups', i)"
>
Matchups
</h2>
</div>
<div v-if="tierlistList == true" style="margin-top: 30px">
<h2 style="padding-left: 20px; font-size: 2.4rem; margin-bottom: 10px">Tierlist</h2>
<div v-if="tierlistList == true" style="margin-top: 20px">
<h2 style="padding-left: 15px; font-size: 1.8rem; margin-bottom: 8px">Tierlist</h2>
<NuxtLink
v-for="(pos, i) in POSITIONS"
:key="i"
@@ -121,16 +112,16 @@ if (route.path.startsWith('/tierlist/')) {
<div
:class="selected == pos ? 'sidebar-link-selected' : ''"
class="sidebar-link"
style="padding-left: 35px; display: flex; align-items: center"
style="padding-left: 25px; display: flex; align-items: center"
>
<NuxtImg
format="webp"
width="40"
height="40"
width="30"
height="30"
:src="LANE_IMAGES[i]"
:alt="POSITIONS_STR[i]"
/>
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px">
<h3 style="font-size: 1.6rem; font-weight: 200; margin-left: 8px">
{{ POSITIONS_STR[i] }}
</h3>
</div>
@@ -139,14 +130,14 @@ if (route.path.startsWith('/tierlist/')) {
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px">
<template v-if="stats">
<h3 style="font-size: 23px; font-weight: 200">Patch {{ stats.patch }}</h3>
<h3 style="font-size: 23px; font-weight: 200">{{ stats.count }} games</h3>
<h3 style="font-size: 18px; font-weight: 200">Patch {{ stats.patch }}</h3>
<h3 style="font-size: 18px; font-weight: 200">{{ stats.count }} games</h3>
</template>
<template v-else>
<h3 style="font-size: 23px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
</template>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px">
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot Games,

View File

@@ -8,17 +8,13 @@ const props = defineProps<{
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
const { data: perks_data }: PerksResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
)
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
const perks = reactive(new Map())
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
const { data: stylesData }: PerkStylesResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
)
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
watch(
() => props.primaryStyleId,
async (_newP, _oldP) => {

View File

@@ -1,155 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
runes: Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
}>()
const currentlySelectedPage = ref(0)
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const keystoneIds: Ref<Array<number>> = ref(Array(props.runes.length))
const { data: perks_data }: PerksResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
)
const perks = reactive(new Map())
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
const { data: stylesData }: PerkStylesResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
)
watch(
() => props.runes,
(_newRunes, _oldRunes) => {
currentlySelectedPage.value = 0
primaryStyles.value = Array(props.runes.length)
secondaryStyles.value = Array(props.runes.length)
keystoneIds.value = Array(props.runes.length)
refreshStylesKeystones()
}
)
function refreshStylesKeystones() {
for (const style of stylesData.value.styles) {
for (const rune of props.runes) {
if (style.id == rune.primaryStyle) {
primaryStyles.value[props.runes.indexOf(rune)] = style
for (const perk of style.slots[0].perks) {
if (rune.selections.includes(perk)) {
keystoneIds.value[props.runes.indexOf(rune)] = perk
}
}
}
if (style.id == rune.secondaryStyle) {
secondaryStyles.value[props.runes.indexOf(rune)] = style
}
}
}
}
refreshStylesKeystones()
function runeSelect(index: number) {
currentlySelectedPage.value = index
}
</script>
<template>
<div style="width: fit-content">
<RunePage
v-if="runes[currentlySelectedPage] != undefined && runes[currentlySelectedPage] != null"
style="margin: auto; width: fit-content"
:primary-style-id="runes[currentlySelectedPage].primaryStyle"
:secondary-style-id="runes[currentlySelectedPage].secondaryStyle"
:selection-ids="runes[currentlySelectedPage].selections"
/>
<div style="display: flex; margin-top: 20px; justify-content: center">
<div v-for="(_, i) in runes" :key="i" @click="runeSelect(i)">
<div
:class="
'rune-selector-entry ' +
(i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')
"
>
<div class="rs-styles-container">
<NuxtImg
v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)"
/>
<NuxtImg
v-if="keystoneIds[i] != null && keystoneIds[i] != undefined"
class="rs-style-img"
width="34"
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)"
/>
</div>
</div>
<h3 class="rs-pickrate">{{ (runes[i].pickrate * 100).toFixed(2) }}% pick.</h3>
</div>
</div>
</div>
</template>
<style>
.rune-selector-entry {
width: 200px;
height: 120px;
margin-left: 10px;
margin-right: 10px;
border-radius: 8%;
border: 1px solid var(--color-on-surface);
}
.rune-selector-entry:hover {
cursor: pointer;
}
.rune-selector-entry-selected {
background-color: var(--color-surface-darker);
}
.rs-styles-container {
display: flex;
margin-top: 20px;
}
.rs-pickrate {
text-align: center;
margin-top: -40px;
padding-bottom: 40px;
}
@media only screen and (max-width: 650px) {
.rune-selector-entry {
width: 100px;
height: 60px;
margin-left: 5px;
margin-right: 5px;
}
.rs-styles-container {
margin-top: 17px;
}
.rs-pickrate {
margin-top: 5px;
padding-bottom: 0px;
}
.rs-style-img {
width: 24px;
height: 24px;
}
}
</style>

View File

@@ -0,0 +1,19 @@
/**
* Composable for managing build data
*/
import { deepClone } from '~/utils/helpers'
export const useBuilds = (buildsProp: Ref<Builds>) => {
const builds = ref<Builds>(deepClone(buildsProp.value))
// Watch for changes and rebuild
watch(
() => buildsProp.value,
newBuilds => {
builds.value = deepClone(newBuilds)
},
{ deep: true }
)
return { builds }
}

View File

@@ -0,0 +1,30 @@
/**
* Composable for fetching and managing item data from CDragon API
* Returns a reactive Map of item ID to item data
*/
export const useItemMap = () => {
const { data: items } = useFetch<Array<Item>>('/api/cdragon/items', {
lazy: true,
server: false
})
const itemMap = ref<Map<number, Item>>(new Map())
watch(
items,
newItems => {
if (Array.isArray(newItems)) {
const map = new Map<number, Item>()
for (const item of newItems) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
},
{ immediate: true }
)
return { itemMap }
}

View File

@@ -0,0 +1,46 @@
/**
* Composable for fetching and managing rune styles and keystones
* Transforms rune data into format needed for display components
*/
export const useRuneStyles = () => {
const { data: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
console.log(stylesData.value)
const perks = reactive(new Map<number, Perk>())
watch(
perksData,
newPerks => {
if (Array.isArray(newPerks)) {
perks.clear()
for (const perk of newPerks) {
if (perk?.id) {
perks.set(perk.id, perk)
}
}
}
},
{ immediate: true }
)
const perkStyles = reactive(new Map<number, PerkStyle>())
watch(
stylesData,
newPerkStyles => {
if (Array.isArray(newPerkStyles?.styles)) {
perkStyles.clear()
for (const perkStyle of newPerkStyles.styles) {
if (perkStyle?.id) {
perkStyles.set(perkStyle.id, perkStyle)
}
}
}
},
{ immediate: true }
)
return {
perks,
perkStyles
}
}

View File

@@ -0,0 +1,33 @@
/**
* Composable for fetching and managing summoner spell data from CDragon API
* Returns a reactive Map of spell ID to spell data
*/
export const useSummonerSpellMap = () => {
const { data: summonerSpellsData } = useFetch<Array<SummonerSpell>>(
'/api/cdragon/summoner-spells',
{
lazy: true,
server: false
}
)
const summonerSpellMap = ref<Map<number, SummonerSpell>>(new Map())
watch(
summonerSpellsData,
newData => {
if (Array.isArray(newData)) {
const map = new Map<number, SummonerSpell>()
for (const spell of newData) {
if (spell?.id) {
map.set(spell.id, spell)
}
}
summonerSpellMap.value = map
}
},
{ immediate: true }
)
return { summonerSpellMap }
}

View File

@@ -30,7 +30,10 @@ export default withNuxt([
ChampionsResponse: 'readonly',
ChampionResponse: 'readonly',
ItemResponse: 'readonly',
MatchupData: 'readonly'
MatchupData: 'readonly',
Item: 'readonly',
SummonerSpell: 'readonly',
Perk: 'readonly'
}
},
rules: {

View File

@@ -8,7 +8,14 @@ const championAlias = route.params.alias as string
const isLoading = ref(true)
const error = ref<string | null>(null)
const laneState = ref(0)
const state = ref('runes')
const state = ref('build')
// Data fetching
const { itemMap } = useItemMap()
const { perks } = useRuneStyles()
// State for selected variant in alternatives tab
const selectedAltVariant = ref(0)
// Use useAsyncData with client-side fetching for faster initial page load
const {
@@ -156,34 +163,57 @@ function fetchChampionData() {
/>
<div id="champion-content">
<ChampionTitle
v-if="championData.gameCount > 0 && lane"
id="champion-title"
:champion-id="championId"
:winrate="lane.winrate || 0"
:pickrate="lane.pickrate || 0"
:game-count="lane.count || 0"
/>
<ClientOnly>
<LazyRuneSelector
v-if="state == 'runes' && championData.gameCount > 0 && lane?.runes"
style="margin: auto; margin-top: 40px"
:runes="lane.runes"
<div class="champion-header">
<ChampionTitle
v-if="championData.gameCount > 0 && lane"
id="champion-title"
:champion-id="championId"
:winrate="lane.winrate || 0"
:pickrate="lane.pickrate || 0"
:game-count="lane.count || 0"
/>
</ClientOnly>
<SummonerSpells v-if="lane" :summoner-spells="lane.summonerSpells" />
</div>
<ClientOnly>
<LazyItemViewer
v-if="state == 'items' && championData.gameCount > 0 && lane?.builds"
<LazyBuildViewer
v-if="state == 'build' && championData.gameCount > 0 && lane?.builds"
style="margin: auto; margin-top: 40px"
:builds="lane.builds"
/>
</ClientOnly>
<ClientOnly>
<LazyItemTree
v-if="state == 'alternatives' && championData.gameCount > 0 && lane?.builds?.tree"
style="margin: auto; margin-top: 40px; width: fit-content"
:tree="lane.builds.tree"
/>
<div
v-if="state == 'alternatives' && championData.gameCount > 0 && lane && lane.builds"
style="
margin: auto;
margin-top: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
"
>
<div style="display: flex">
<LazyBuildVariantSelector
v-for="(build, i) in lane.builds"
:key="i"
:keystone-id="build.runeKeystone"
:item-id="build.items.children[0].data"
:keystore="perks"
:item-map="itemMap"
:pickrate="build.pickrate"
:selected="selectedAltVariant == i"
:index="i"
@select="selectedAltVariant = i"
/>
</div>
<LazyItemTree
v-if="lane.builds[selectedAltVariant]?.items"
style="width: fit-content"
:tree="lane.builds[selectedAltVariant].items"
/>
</div>
</ClientOnly>
<ClientOnly>
<LazyMatchupSection
@@ -216,8 +246,10 @@ function fetchChampionData() {
}
#champion-content {
margin-top: 64px;
margin-left: 39px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* Loading state styles */
@@ -296,6 +328,15 @@ function fetchChampionData() {
background-color: #45a049;
}
/* Champion header layout */
.champion-header {
display: flex;
align-items: center;
margin: auto;
width: fit-content;
gap: 40px;
}
@media only screen and (max-width: 650px) {
#champion-content {
margin: auto;
@@ -304,7 +345,18 @@ function fetchChampionData() {
#champion-title {
margin: auto;
}
.champion-header {
flex-direction: column;
gap: 20px;
}
.layout-selector {
flex-wrap: wrap;
justify-content: center;
}
}
@media only screen and (max-width: 1200px) {
#alias-content-wrapper {
flex-direction: column;

View File

@@ -4,9 +4,7 @@ import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon
const route = useRoute()
const lane = route.params.lane as string
const { data: championsData }: ChampionsResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const { data: championsData }: ChampionsResponse = await useFetch('/api/cdragon/champion-summary')
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
await useFetch('/api/champions')

View File

@@ -0,0 +1,14 @@
import { getChampionSummary } from '~/server/utils/cdragon-cache'
export default defineEventHandler(async () => {
try {
const championSummary = await getChampionSummary()
return championSummary
} catch (error) {
console.error('Error fetching champion summary:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch champion summary'
})
}
})

View File

@@ -0,0 +1,14 @@
import { getItems } from '~/server/utils/cdragon-cache'
export default defineEventHandler(async () => {
try {
const items = await getItems()
return items
} catch (error) {
console.error('Error fetching items:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch items'
})
}
})

View File

@@ -0,0 +1,14 @@
import { getPerks } from '~/server/utils/cdragon-cache'
export default defineEventHandler(async () => {
try {
const perks = await getPerks()
return perks
} catch (error) {
console.error('Error fetching perks:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch perks'
})
}
})

View File

@@ -0,0 +1,14 @@
import { getPerkStyles } from '~/server/utils/cdragon-cache'
export default defineEventHandler(async () => {
try {
const perkStyles = await getPerkStyles()
return perkStyles
} catch (error) {
console.error('Error fetching perk styles:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch perk styles'
})
}
})

View File

@@ -0,0 +1,14 @@
import { getSummonerSpells } from '~/server/utils/cdragon-cache'
export default defineEventHandler(async () => {
try {
const summonerSpells = await getSummonerSpells()
return summonerSpells
} catch (error) {
console.error('Error fetching summoner spells:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch summoner spells'
})
}
})

View File

@@ -1,11 +1,7 @@
import { CDRAGON_BASE } from '~/utils/cdragon'
import { getChampionSummary } from '~/server/utils/cdragon-cache'
async function championRoutes() {
const championsData: Array<Champion> = await (
await fetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
).json()
const championsData = await getChampionSummary()
const routes: Array<string> = []
for (const champion of championsData) {

View File

@@ -0,0 +1,205 @@
import { readFile, existsSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
const readFileAsync = promisify(readFile)
// CDragon base URL for fallback
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Cache directory - can be configured via environment variable
// Default to dev/cdragon for development
const getCacheDir = () => {
if (process.env.CDRAGON_CACHE_DIR) {
return process.env.CDRAGON_CACHE_DIR
}
// Default to dev/cdragon relative to project root
return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
}
/**
* Get the current patch from the patch.txt file or fallback to 'latest'
* Converts patch format for CDragon: "16.4.1" -> "16.4"
*/
async function getCurrentPatch(): Promise<string> {
const cacheDir = getCacheDir()
const patchFile = join(cacheDir, 'latest', 'patch.txt')
if (existsSync(patchFile)) {
const patch = await readFileAsync(patchFile, 'utf-8')
const trimmedPatch = patch.trim()
// Convert patch format for CDragon: "16.4.1" -> "16.4"
return trimmedPatch.split('.').slice(0, 2).join('.')
}
// Fallback to 'latest' if no patch file exists
return 'latest'
}
/**
* Cached CDragon data types
*/
interface CDragonCacheOptions {
patch?: string
}
/**
* Fetch data from local cache with CDragon fallback
*/
async function fetchFromCache<T>(
assetName: string,
cdragonPath: string,
options?: CDragonCacheOptions
): Promise<T> {
const cacheDir = getCacheDir()
const patch = options?.patch || (await getCurrentPatch())
const cachePath = join(cacheDir, patch, assetName)
// Try to read from cache first
if (existsSync(cachePath)) {
try {
const data = await readFileAsync(cachePath, 'utf-8')
return JSON.parse(data) as T
} catch (error) {
console.error(`Error reading cache file ${cachePath}:`, error)
}
}
// Fallback to CDragon
console.log(`Cache miss for ${assetName}, fetching from CDragon...`)
const url = `${CDRAGON_BASE}${patch}/${cdragonPath}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${assetName} from CDragon: ${response.status}`)
}
return (await response.json()) as T
}
/**
* Get items data from cache
*/
async function getItems(patch?: string): Promise<CDragonItem[]> {
return fetchFromCache<CDragonItem[]>(
'items.json',
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
{ patch }
)
}
/**
* Get perks (runes) data from cache
*/
async function getPerks(patch?: string): Promise<CDragonPerk[]> {
return fetchFromCache<CDragonPerk[]>(
'perks.json',
'plugins/rcp-be-lol-game-data/global/default/v1/perks.json',
{ patch }
)
}
/**
* Get perk styles data from cache
*/
async function getPerkStyles(patch?: string): Promise<CDragonPerkStyles> {
return fetchFromCache<CDragonPerkStyles>(
'perkstyles.json',
'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json',
{ patch }
)
}
/**
* Get summoner spells data from cache
*/
async function getSummonerSpells(patch?: string): Promise<CDragonSummonerSpell[]> {
return fetchFromCache<CDragonSummonerSpell[]>(
'summoner-spells.json',
'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json',
{ patch }
)
}
/**
* Get champion summary data from cache
*/
async function getChampionSummary(patch?: string): Promise<CDragonChampionSummary[]> {
return fetchFromCache<CDragonChampionSummary[]>(
'champion-summary.json',
'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json',
{ patch }
)
}
// Type definitions for CDragon data
interface CDragonItem {
id: number
name: string
iconPath: string
description?: string
plaintext?: string
into?: number[]
from?: number[]
gold?: {
base: number
total: number
sell: number
}
}
interface CDragonPerk {
id: number
name: string
iconPath: string
shortDesc?: string
longDesc?: string
}
interface CDragonPerkStyle {
id: number
name: string
iconPath: string
slots: Array<{
type: string
perks: number[]
}>
}
interface CDragonPerkStyles {
styles: CDragonPerkStyle[]
}
interface CDragonSummonerSpell {
id: number
name: string
iconPath: string
description?: string
}
interface CDragonChampionSummary {
id: number
name: string
alias: string
squarePortraitPath: string
roles?: string[]
}
export {
getItems,
getPerks,
getPerkStyles,
getSummonerSpells,
getChampionSummary,
getCurrentPatch,
CDRAGON_BASE
}
export type {
CDragonItem,
CDragonPerk,
CDragonPerkStyle,
CDragonPerkStyles,
CDragonSummonerSpell,
CDragonChampionSummary
}

View File

@@ -9,17 +9,25 @@ declare global {
}
/**
* Represents champion build information
* Represents a complete build with runes and items
*/
interface Builds {
start: Array<{ count: number; data: number }>
tree: ItemTree
interface Build {
runeKeystone: number
runes: Rune[]
items: ItemTree
bootsFirst: number
count: number
boots: Array<{ count: number; data: number }>
lateGame: Array<{ count: number; data: number }>
suppItems?: Array<{ count: number; data: number }>
suppItems: Array<{ count: number; data: number }>
startItems: Array<{ count: number; data: number }>
pickrate: number
}
/**
* Represents champion build information (array of builds)
*/
type Builds = Array<Build>
/**
* Represents a rune configuration
*/
@@ -52,8 +60,8 @@ declare global {
losingMatches: number
winrate: number
pickrate: number
runes?: Rune[]
builds?: Builds
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
matchups?: MatchupData[]
}

View File

@@ -21,6 +21,19 @@ declare global {
}
type Item = {
id: number
iconPath: string
name?: string
description?: string
plaintext?: string
into?: number[]
from?: number[]
price?: number
priceTotal?: number
}
type SummonerSpell = {
id: number
iconPath: string
name: string
}
type PerksResponse = {
data: Ref<Array<Perk>>

View File

@@ -0,0 +1,89 @@
/**
* Gets all late game items from the item tree (items beyond first level)
* Returns a flat array of unique items with their counts
*/
export function getLateGameItems(build: Build): Array<{ data: number; count: number }> {
const lateGameItems: Array<{ data: number; count: number }> = []
const itemCounts = new Map<number, number>()
// Collect late items
function collectLateItems(tree: ItemTree, depth: number = 0): void {
if (depth >= 3 && tree.data !== undefined && tree.count > 0) {
const existing = itemCounts.get(tree.data) || 0
itemCounts.set(tree.data, existing + tree.count)
}
for (const child of tree.children) {
collectLateItems(child, depth + 1)
}
}
collectLateItems(build.items)
// Convert map to array
for (const [data, count] of itemCounts.entries()) {
lateGameItems.push({ data, count })
}
lateGameItems.sort((a, b) => b.count - a.count)
console.log(lateGameItems)
// Sort by count descending
return lateGameItems.filter(item => !treeToArray(getCoreItems(build)).includes(item.data))
}
function treeToArray(tree: ItemTree): Array<number> {
const arr: Array<number> = []
if (tree.data != null) arr.push(tree.data)
for (const child of tree.children) arr.push(...treeToArray(child))
return arr
}
/**
* Creates a deep copy of an ItemTree trimmed to a maximum depth
* @param tree - The item tree to copy and trim
* @param maxDepth - The maximum depth to keep (inclusive)
* @param currentDepth - The current depth during recursion
* @returns A new ItemTree with children trimmed beyond maxDepth
*/
function trimTreeDepth(tree: ItemTree, maxDepth: number, currentDepth: number = 0): ItemTree {
const trimmedTree: ItemTree = {
count: tree.count,
data: tree.data,
children: []
}
// If we haven't reached maxDepth, include children
if (currentDepth < maxDepth) {
for (const child of tree.children || []) {
trimmedTree.children.push(trimTreeDepth(child, maxDepth, currentDepth + 1))
}
}
return trimmedTree
}
function trimTreeChildrensAtDepth(tree: ItemTree, maxChildren: number, depth: number) {
if (depth == 0) {
if (tree.children.length > maxChildren) {
tree.children.splice(maxChildren, tree.children.length - maxChildren)
}
return
}
for (const c of tree.children) {
trimTreeChildrensAtDepth(c, maxChildren, depth - 1)
}
}
export function getCoreItems(build: Build): ItemTree {
const tree = trimTreeDepth(build.items, 3)
trimTreeChildrensAtDepth(tree, 1, 0)
trimTreeChildrensAtDepth(tree, 1, 1)
trimTreeChildrensAtDepth(tree, 3, 2)
return tree
}

View File

@@ -7,7 +7,15 @@ function sameArrays(array1: Array<number>, array2: Array<number>) {
}
import { MongoClient } from 'mongodb'
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from './item_tree'
import {
ItemTree,
treeInit,
treeMerge,
treeCutBranches,
treeSort,
treeMergeTree,
areTreeSimilars
} from './item_tree'
const itemDict = new Map()
async function itemList() {
@@ -41,14 +49,32 @@ type Rune = {
selections: Array<number>
pickrate?: number
}
type Builds = {
tree: ItemTree
start: Array<{ data: number; count: number }>
bootsFirst: number
type Build = {
runeKeystone: number
runes: Array<Rune>
items: ItemTree
bootsFirstCount: number
bootsFirst?: number
count: number
suppItems: Array<{ data: number; count: number }>
boots: Array<{ data: number; count: number }>
lateGame: Array<{ data: number; count: number }>
suppItems?: Array<{ data: number; count: number }>
pickrate?: number
}
type BuildWithStartItems = {
runeKeystone: number
runes: Array<Rune>
items: ItemTree
bootsFirst?: number
bootsFirstCount: number
count: number
startItems: Array<{ data: number; count: number }>
suppItems: Array<{ data: number; count: number }>
boots: Array<{ data: number; count: number }>
pickrate?: number
}
type Builds = Build[]
type Champion = {
id: number
name: string
@@ -69,9 +95,9 @@ type LaneData = {
losingMatches: number
winrate: number
pickrate: number
runes: Array<Rune>
builds: Builds
matchups?: Array<MatchupData>
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
}
type ChampionData = {
champion: Champion
@@ -80,8 +106,9 @@ type ChampionData = {
lanes: Array<LaneData>
}
// Helper function to create rune configuration from participant
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleParticipantRunes(participant: any, runes: Array<Rune>) {
function createRuneConfiguration(participant: any): Rune {
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = []
@@ -90,28 +117,56 @@ function handleParticipantRunes(participant: any, runes: Array<Rune>) {
selections.push(perk.perk)
}
}
const gameRunes: Rune = {
count: 1,
return {
count: 0, // Will be incremented when added to build
primaryStyle: primaryStyle,
secondaryStyle: secondaryStyle,
selections: selections
}
let addRunes = true
for (const rune of runes) {
if (
rune.primaryStyle == gameRunes.primaryStyle &&
rune.secondaryStyle == gameRunes.secondaryStyle &&
sameArrays(rune.selections, gameRunes.selections)
) {
rune.count++
addRunes = false
break
}
}
if (addRunes) runes.push(gameRunes)
}
function handleMatchItems(
// Find or create a build for the given rune keystone
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findOrCreateBuild(builds: Builds, participant: any): Build {
const keystone = participant.perks.styles[0].selections[0].perk
const runeConfig = createRuneConfiguration(participant)
// Try to find existing build with matching keystone
const existingBuild = builds.find(
build =>
build.runes[0].primaryStyle === runeConfig.primaryStyle && build.runeKeystone === keystone
)
if (existingBuild) {
// Check if this rune configuration already exists in the build
const existingRune = existingBuild.runes.find(rune =>
sameArrays(rune.selections, runeConfig.selections)
)
if (existingRune) {
existingRune.count++
} else {
existingBuild.runes.push({ ...runeConfig, count: 1 })
}
return existingBuild
}
// Create new build for this keystone
const newBuild: Build = {
runeKeystone: keystone,
runes: [{ ...runeConfig, count: 1 }],
items: treeInit(),
bootsFirstCount: 0,
count: 0,
suppItems: [],
boots: []
}
builds.push(newBuild)
return newBuild
}
function handleMatchBuilds(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
timeline: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -119,6 +174,10 @@ function handleMatchItems(
participantIndex: number,
builds: Builds
) {
// Find or create the build for this participant's rune configuration
const build = findOrCreateBuild(builds, participant)
build.count += 1
const items: Array<number> = []
for (const frame of timeline.info.frames) {
for (const event of frame.events) {
@@ -145,8 +204,8 @@ function handleMatchItems(
x == participant.item6
)
if (suppItem != undefined) {
const already = builds.suppItems.find(x => x.data == suppItem)
if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem })
const already = build.suppItems.find(x => x.data == suppItem)
if (already == undefined) build.suppItems.push({ count: 1, data: suppItem })
else already.count += 1
}
}
@@ -166,12 +225,12 @@ function handleMatchItems(
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if (items.length < 2) {
builds.bootsFirst += 1
build.bootsFirstCount += 1
}
// Add to boots
const already = builds.boots.find(x => x.data == event.itemId)
if (already == undefined) builds.boots.push({ count: 1, data: event.itemId })
// Add to boots array
const already = build.boots.find(x => x.data == event.itemId)
if (already == undefined) build.boots.push({ count: 1, data: event.itemId })
else already.count += 1
}
@@ -188,28 +247,18 @@ function handleMatchItems(
// Ignore Cull as not-first item
if (event.itemId == 1083 && items.length >= 1) continue
// Ignore non-final items, except when first item bought
if (itemInfo.to.length != 0 && items.length >= 1) continue
// Ignore non-final items, except when first item bought or support role
if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY'))
continue
items.push(event.itemId)
}
}
// Core items
treeMerge(builds.tree, items.slice(1, 4))
// Start items
if (items.length >= 1) {
const already = builds.start.find(x => x.data == items[0])
if (already == undefined) builds.start.push({ count: 1, data: items[0] })
else already.count += 1
}
// Late game items
for (const item of items.slice(3)) {
const already = builds.lateGame.find(x => x.data == item)
if (already == undefined) builds.lateGame.push({ count: 1, data: item })
else already.count += 1
// Merge the full item path into the build's item tree
// This tree includes start item as the root, then branching paths
if (items.length > 0) {
treeMerge(build.items, items)
}
}
@@ -224,23 +273,15 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
// Lanes
let lane = champion.lanes.find(x => x.data == participant.teamPosition)
if (lane == undefined) {
const builds: Builds = {
tree: treeInit(),
start: [],
bootsFirst: 0,
boots: [],
lateGame: [],
suppItems: []
}
lane = {
count: 1,
data: participant.teamPosition,
runes: [],
builds: builds,
builds: [],
winningMatches: 0,
losingMatches: 0,
winrate: 0,
pickrate: 0,
summonerSpells: [],
matchups: []
}
champion.lanes.push(lane)
@@ -260,6 +301,18 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
lane.losingMatches++
}
// Summoner spells
let spell1 = lane.summonerSpells.find(x => x.id == participant.summoner1Id)
if (spell1 == undefined) {
spell1 = { id: participant.summoner1Id, count: 1, pickrate: undefined }
lane.summonerSpells.push(spell1)
} else spell1.count += 1
let spell2 = lane.summonerSpells.find(x => x.id == participant.summoner2Id)
if (spell2 == undefined) {
spell2 = { id: participant.summoner2Id, count: 1, pickrate: undefined }
lane.summonerSpells.push(spell2)
} else spell2.count += 1
// Track counter matchups - find opponent in same lane
const opponentTeam = participant.teamId === 100 ? 200 : 100
const opponent = match.info.participants.find(
@@ -292,11 +345,8 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
}
}
// Runes
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
// Items and runes (builds)
handleMatchBuilds(match.timeline, participant, participantIndex, lane.builds)
}
}
@@ -322,50 +372,223 @@ async function handleMatchList(
return totalMatches
}
// Split or merge a build/buildtree on starter items
// If starter items have a rest-of-tree that is too different, we split
// into two variants.
// Otherwise, we merge into a ProcessedBuild that has a tree without starters
function splitMergeOnStarterItem(build: Build, championName: string): BuildWithStartItems[] {
if (build.items.children.length > 2) {
console.log(
`Warning: We have more than 2 starter items for champion ${championName}. Current algorithm won't work.`
)
}
if (
build.items.children.length <= 1 ||
areTreeSimilars(build.items.children[0], build.items.children[1]) >= 0.5
) {
const startItems = []
let items = build.items.children[0]
startItems.push({ data: build.items.children[0].data, count: build.items.children[0].count })
build.items.children[0].data = undefined
if (build.items.children.length > 1) {
startItems.push({ data: build.items.children[1].data, count: build.items.children[1].count })
build.items.children[1].data = undefined
items = treeMergeTree(build.items.children[0], build.items.children[1])
}
return [
{
runeKeystone: build.runeKeystone,
runes: build.runes,
items,
bootsFirstCount: build.bootsFirstCount,
count: build.count,
startItems,
suppItems: build.suppItems,
boots: build.boots,
pickrate: build.pickrate
}
]
} else {
// Trees are different. We separate into two build variants
console.log(`Warning: for champion ${championName}, start item splits build variant.`)
const builds = []
for (const c of build.items.children) {
builds.push({
runeKeystone: build.runeKeystone,
runes: build.runes,
items: c,
bootsFirstCount: build.bootsFirstCount,
count: c.count,
startItems: [{ data: c.data, count: c.count }],
suppItems: build.suppItems,
boots: build.boots
})
c.data = undefined
}
return builds
}
}
// Helper function to merge item counts with same data
function mergeItemCounts(
builds: BuildWithStartItems[],
itemsGetter: (build: BuildWithStartItems) => Array<{ data: number; count: number }>
): Array<{ data: number; count: number }> {
const countsMap = new Map<number, number>()
for (const build of builds) {
const items = itemsGetter(build)
if (!items) continue
for (const item of items) {
const existing = countsMap.get(item.data)
if (existing !== undefined) {
countsMap.set(item.data, existing + item.count)
} else {
countsMap.set(item.data, item.count)
}
}
}
return Array.from(countsMap.entries()).map(([data, count]) => ({ data, count }))
}
// Merge different builds that have the same items (item trees similar) but different
// runes (primary style and keystones)
function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems[] {
const merged: BuildWithStartItems[] = []
const processed = new Set<number>()
const sortedBuilds = [...builds].sort((a, b) => b.count - a.count)
for (let i = 0; i < sortedBuilds.length; i++) {
if (processed.has(i)) continue
const currentBuild = sortedBuilds[i]
processed.add(i)
// Find all builds with similar item trees
const similarBuildsIndices: number[] = []
for (let j = i + 1; j < sortedBuilds.length; j++) {
if (processed.has(j)) continue
const otherBuild = sortedBuilds[j]
if (areTreeSimilars(currentBuild.items, otherBuild.items) >= 0.5) {
similarBuildsIndices.push(j)
processed.add(j)
}
}
// If no similar builds found, just add the current build as-is
if (similarBuildsIndices.length === 0) {
merged.push(currentBuild)
continue
}
// Merge all similar builds
const allSimilarBuilds = [currentBuild, ...similarBuildsIndices.map(idx => sortedBuilds[idx])]
const totalCount = allSimilarBuilds.reduce((sum, b) => sum + b.count, 0)
// Merge runes - combine all unique rune configurations
const runesMap = new Map<string, Rune>()
for (const build of allSimilarBuilds) {
for (const rune of build.runes) {
const key = `${rune.primaryStyle}-${rune.selections.join('-')}`
const existing = runesMap.get(key)
if (existing) {
existing.count += rune.count
} else {
runesMap.set(key, { ...rune })
}
}
}
const runes = Array.from(runesMap.values())
runes.sort((a, b) => b.count - a.count)
merged.push({
runeKeystone: runes[0].selections[0],
runes: runes,
items: currentBuild.items,
bootsFirstCount: allSimilarBuilds.reduce((sum, b) => sum + b.bootsFirstCount, 0),
count: totalCount,
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
boots: mergeItemCounts(allSimilarBuilds, b => b.boots)
})
}
return merged
}
function cleanupLaneBuilds(lane: LaneData) {
// Filter builds to remove variants that are not played enough
lane.builds = lane.builds.filter(build => build.count / lane.count >= 0.05)
const builds = lane.builds
// Sort builds by count
builds.sort((a, b) => b.count - a.count)
// For each build: prune item tree, clean up boots, calculate percentages
for (const build of builds) {
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
build.items.count = build.count
treeCutBranches(build.items, 4, 0.05)
treeSort(build.items)
// Remove boots that are not within percentage threshold
arrayRemovePercentage(build.boots, build.count, 0.05)
build.boots.sort((a, b) => b.count - a.count)
// Remove support items that are not within percentage threshold
arrayRemovePercentage(build.suppItems, build.count, 0.05)
build.suppItems.sort((a, b) => b.count - a.count)
// Calculate bootsFirst percentage
build.bootsFirst = build.bootsFirstCount / build.count
// Compute runes pickrate, and filter out to keep only top 3
build.runes.forEach(rune => (rune.pickrate = rune.count / build.count))
build.runes.sort((a, b) => b.count - a.count)
if (build.runes.length > 3) build.runes.splice(3, build.runes.length - 3)
build.pickrate = build.count / lane.count
}
}
async function finalizeChampionStats(champion: ChampionData, totalMatches: number) {
const totalChampionMatches = champion.winningMatches + champion.losingMatches
arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2)
champion.lanes.sort((a, b) => b.count - a.count)
// Filter runes to keep 3 most played
for (const lane of champion.lanes) {
const runes = lane.runes
// Summoner spells
lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count))
lane.summonerSpells.sort((a, b) => b.count - a.count)
lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate >= 0.05)
runes.sort((a, b) => b.count - a.count)
if (runes.length > 3) runes.splice(3, runes.length - 3)
// Compute runes pickrate
for (const rune of runes) rune.pickrate = rune.count / lane.count
}
// Cleaning up builds
cleanupLaneBuilds(lane)
for (const lane of champion.lanes) {
const builds = lane.builds
// Now, second stage: clustering and de-clustering
// First, we split the builds on starter items, to obtain a BuildWithStartItems.
if (lane.data != 'UTILITY') {
const newBuilds: BuildWithStartItems[] = []
for (const build of lane.builds) {
newBuilds.push(...splitMergeOnStarterItem(build, champion.champion.name))
}
lane.builds = newBuilds
cleanupLaneBuilds(lane)
}
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
builds.tree.count = lane.count
treeCutBranches(builds.tree, 4, 0.05)
treeSort(builds.tree)
// Cut item start, to only 4 and with percentage threshold
arrayRemovePercentage(builds.start, lane.count, 0.05)
builds.start.sort((a, b) => b.count - a.count)
if (builds.start.length > 4) builds.start.splice(4, builds.start.length - 4)
// Remove boots that are not within percentage threshold
arrayRemovePercentage(builds.boots, lane.count, 0.05)
builds.boots.sort((a, b) => b.count - a.count)
builds.bootsFirst /= lane.count
// Cut supp items below 2 and percentage threshold
arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
builds.suppItems.sort((a, b) => b.count - a.count)
if (builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2)
// Delete supp items if empty
if (builds.suppItems.length == 0) delete builds.suppItems
builds.lateGame.sort((a, b) => b.count - a.count)
// Finally, we merge the builds that are similar but have different keystones.
// Now that we split everything that needed to be split, we are sure that we don't need
// to have the data per-keystone. We can just merge them back, as it was the same build
// all along.
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
cleanupLaneBuilds(lane)
}
for (const lane of champion.lanes) {

View File

@@ -81,4 +81,97 @@ function treeSort(itemtree: ItemTree) {
}
}
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort }
/*
* Deep clone an ItemTree
*/
function treeClone(tree: ItemTree): ItemTree {
return {
data: tree.data,
count: tree.count,
children: tree.children.map(child => treeClone(child))
}
}
/*
* Merge two ItemTrees into one
*/
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
// Merge counts for the root
t1.count += t2.count
// Merge children from t2 into t1
for (const child2 of t2.children) {
// Find matching child in t1 (same data value)
const matchingChild = t1.children.find(child1 => child1.data === child2.data)
if (matchingChild) {
// Recursively merge matching children
treeMergeTree(matchingChild, child2)
} else {
// Add a deep copy of child2 to t1
t1.children.push(treeClone(child2))
}
}
return t1
}
/*
* Flatten an ItemTree into a Set of item numbers
*/
function treeToSet(itemtree: ItemTree): Set<number> {
const items: Set<number> = new Set()
function traverse(node: ItemTree) {
if (node.data !== undefined) {
items.add(node.data)
}
for (const child of node.children) {
traverse(child)
}
}
traverse(itemtree)
return items
}
/*
* Calculate similarity between two trees as item sets.
* Returns a number between 0 and 1, where 1 means identical and 0 means completely different.
* Uses Jaccard similarity: |A ∩ B| / |A B|
* Sets included in one another will have similarity close to 1.
*/
function areTreeSimilars(t1: ItemTree, t2: ItemTree): number {
const set1 = treeToSet(t1)
const set2 = treeToSet(t2)
// Handle empty sets
if (set1.size === 0 && set2.size === 0) {
return 1.0
}
// Calculate intersection
const intersection = new Set<number>()
for (const item of Array.from(set1)) {
if (set2.has(item)) {
intersection.add(item)
}
}
// Calculate union
const union = new Set<number>()
for (const item of Array.from(set1)) {
union.add(item)
}
for (const item of Array.from(set2)) {
union.add(item)
}
// Jaccard similarity: |intersection| / |union|
const similarity = intersection.size / Math.min(set1.size, set2.size)
// Ensure result is between 0 and 1
return Math.max(0, Math.min(1, similarity))
}
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars }

View File

@@ -1,16 +1,62 @@
import { MongoClient } from 'mongodb'
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
// CDragon base URL for specific patch
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Assets to cache for each patch
const CDRAGON_ASSETS = [
{
name: 'items.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
},
{
name: 'perks.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
},
{
name: 'perkstyles.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
},
{
name: 'summoner-spells.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json'
},
{
name: 'champion-summary.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
}
]
main()
async function main() {
const client = await connectToDatabase()
const dbPatch = await getLatestPatchFromDatabase(client)
// In dev mode, get patch from database (the one we have match data for)
if (process.env.NODE_ENV === 'development') {
console.log('Development mode: downloading cache for database patch')
await client.close()
if (dbPatch) {
console.log('Latest patch in database: ' + dbPatch)
await downloadAssets(dbPatch)
} else {
console.log('No patch found in database!')
}
return
}
// Production mode: check database and update if new patch
const newPatch = await fetchLatestPatch()
console.log('Latest patch is: ' + newPatch)
const newDate = new Date()
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
downloadAssets()
await downloadAssets(newPatch)
}
await client.close()
@@ -58,4 +104,66 @@ async function compareLatestSavedPatch(client: MongoClient, newPatch: string, ne
return true
}
async function downloadAssets() {}
async function getLatestPatchFromDatabase(client: MongoClient): Promise<string | null> {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
if (latestPatch == null) {
return null
}
return latestPatch.patch
}
async function downloadAssets(patch: string) {
// Convert patch format for CDragon: "16.4.1" -> "16.4"
// CDragon uses patch format without the last minor version
const cdragonPatch = patch.split('.').slice(0, 2).join('.')
console.log(`Downloading CDragon assets for patch ${cdragonPatch} (from ${patch})...`)
// Get cache directory from environment or use default
const cacheDir = process.env.CDRAGON_CACHE_DIR || '/cdragon'
const patchDir = join(cacheDir, cdragonPatch)
// Create patch directory if it doesn't exist
if (!existsSync(patchDir)) {
await mkdir(patchDir, { recursive: true })
console.log(`Created directory: ${patchDir}`)
}
// Download each asset
for (const asset of CDRAGON_ASSETS) {
const url = `${CDRAGON_BASE}${cdragonPatch}/${asset.path}`
const filePath = join(patchDir, asset.name)
try {
console.log(`Downloading ${asset.name}...`)
const response = await fetch(url)
if (!response.ok) {
console.error(`Failed to download ${asset.name}: ${response.status} ${response.statusText}`)
continue
}
const data = await response.json()
await writeFile(filePath, JSON.stringify(data, null, 2))
console.log(`Saved ${asset.name} to ${filePath}`)
} catch (error) {
console.error(`Error downloading ${asset.name}:`, error)
}
}
// Create a symlink or copy to 'latest' directory for easy access
const latestDir = join(cacheDir, 'latest')
const latestFile = join(latestDir, 'patch.txt')
if (!existsSync(latestDir)) {
await mkdir(latestDir, { recursive: true })
}
await writeFile(latestFile, patch)
console.log(`Updated latest patch reference to ${patch}`)
console.log('CDragon assets download complete!')
}