Compare commits

11 Commits

Author SHA1 Message Date
930cbf5a18 frontend: add item tooltip, refactor with itemicon component 2026-03-01 13:42:01 +01:00
f6cf2c8a8c frontend: fix compactruneselector on mobile 2026-02-28 13:53:37 +01:00
8c9da868f4 frontend: lint and format 2026-02-28 13:47:46 +01:00
45fa841f80 frontend: make sidebar smaller 2026-02-28 13:44:27 +01:00
c362d6b12a frontend: refactor build viewer a bit more 2026-02-28 13:38:14 +01:00
7833780bcb frontend: refactor of the new build viewer
extracting the logic into composables
2026-02-28 13:29:33 +01:00
20ccb20738 frontend: refactor of the new build viewer 2026-02-28 13:18:02 +01:00
3e9a8295b2 frontend: fix item tree arrows (Fix #9) 2026-02-28 13:13:01 +01:00
ea27a0d6f8 patch_detector: refactor 2026-02-28 09:28:13 +01:00
fe128c0848 Frontend updates: caching basic data (json) from CDragon
Implement caching in the patch_detector, consume the cache from API routes in frontend
2026-02-28 00:23:04 +01:00
dc09d10f07 Frontend updates and changes
- Remove items/runes separate pages, put everything into a "build" page.
- Show summoner spells.
- Add a build variant selector, that for now only selects runes
2026-02-25 14:59:42 +01:00
37 changed files with 2145 additions and 684 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

@@ -0,0 +1,151 @@
<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
}>()
</script>
<template>
<div class="build-variant-selector">
<div :class="['build-variant-card', { selected: true }]">
<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,128 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface RuneBuild {
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}
interface PerkStyle {
id: number
iconPath: string
}
const props = defineProps<{
runes: Array<RuneBuild>
primaryStyles: Array<PerkStyle>
secondaryStyles: Array<PerkStyle>
keystoneIds: Array<number>
perks: Map<number, Perk>
selectedIndex: number
}>()
const emit = defineEmits<{
select: [index: number]
}>()
</script>
<template>
<div class="compact-rune-selector">
<div
v-for="(rune, index) in props.runes"
:key="index"
:class="['compact-rune-option', { active: index === props.selectedIndex }]"
@click="emit('select', index)"
>
<div class="compact-rune-content">
<NuxtImg
v-if="primaryStyles[index]"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(primaryStyles[index].iconPath)"
/>
<NuxtImg
v-if="keystoneIds[index] && props.perks.get(keystoneIds[index])"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(props.perks.get(keystoneIds[index])!.iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[index]"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(secondaryStyles[index].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,92 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface SummonerSpellData {
id: number
count: number
pickrate: number
}
const props = defineProps<{
spells: Array<SummonerSpellData>
summonerSpellMap: Map<number, SummonerSpell>
}>()
</script>
<template>
<div class="summoner-spells-section">
<h3 class="section-title">Summoner Spells</h3>
<div class="summoner-spells-row">
<div v-for="(spell, i) in props.spells.slice(0, 2)" :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: column;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: #4a9eff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summoner-spells-row {
display: flex;
gap: 8px;
}
.summoner-spell-item {
display: flex;
align-items: center;
gap: 6px;
}
.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.7rem;
color: var(--color-on-surface);
opacity: 0.7;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.section-title {
font-size: 0.9rem;
}
.summoner-spell-img,
.summoner-spell-placeholder {
width: 36px;
height: 36px;
}
}
</style>

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
import { getHighestPickrateBuildIndex, getFirstCoreItems } from '~/utils/buildHelpers'
import { MOCK_SUMMONER_SPELLS } from '~/utils/mockData'
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
import SummonerSpells from '~/components/build/SummonerSpells.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
import ItemRow from '~/components/build/ItemRow.vue'
const props = defineProps<{
runes: Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
builds: Builds
summonerSpells?: Array<{ id: number; count: number; pickrate: number }>
}>()
// State
const currentlySelectedBuild = ref(0)
// Use composables for data fetching
const { itemMap } = useItemMap()
const { summonerSpellMap } = useSummonerSpellMap()
// Use composable for rune styles
const { perks, primaryStyles, secondaryStyles, keystoneIds } = useRuneStyles(toRef(props, 'runes'))
// Use composable for builds management
const { builds } = useBuilds(toRef(props, 'builds'))
// Summoner spells data - use提供的 or fall back to mock
const displaySummonerSpells = computed(() =>
props.summonerSpells && props.summonerSpells.length > 0
? props.summonerSpells
: MOCK_SUMMONER_SPELLS
)
// Computed properties using utility functions
const firstCoreItems = computed(() => getFirstCoreItems(props.runes, builds.value))
const highestPickrateBuildIndex = computed(() => getHighestPickrateBuildIndex(props.runes))
// Reset selected build when runes change
watch(
() => props.runes,
() => {
currentlySelectedBuild.value = 0
}
)
function selectRune(index: number): void {
currentlySelectedBuild.value = index
}
</script>
<template>
<div class="build-viewer">
<!-- Global Build Variant Selector - Single variant with highest pickrate -->
<BuildVariantSelector
:keystone-id="keystoneIds[highestPickrateBuildIndex]"
:item-id="firstCoreItems[highestPickrateBuildIndex]"
:keystore="perks"
:item-map="itemMap"
:pickrate="1"
/>
<!-- Main Build Content -->
<div class="build-content">
<!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column">
<!-- Summoner Spells -->
<SummonerSpells :spells="displaySummonerSpells" :summoner-spell-map="summonerSpellMap" />
<!-- Rune Page -->
<div class="rune-section">
<h3 class="section-title">Runes</h3>
<div class="rune-page-wrapper">
<RunePage
v-if="runes[currentlySelectedBuild]"
:primary-style-id="runes[currentlySelectedBuild].primaryStyle"
:secondary-style-id="runes[currentlySelectedBuild].secondaryStyle"
:selection-ids="runes[currentlySelectedBuild].selections"
/>
</div>
<!-- Compact Rune Selector -->
<CompactRuneSelector
:runes="runes"
:primary-styles="primaryStyles"
:secondary-styles="secondaryStyles"
:keystone-ids="keystoneIds"
:perks="perks"
: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 Items -->
<ItemRow
v-if="!builds.suppItems"
label="Start"
:items="builds.start"
:item-map="itemMap"
:total-count="builds.tree.count"
/>
<!-- Support Items -->
<ItemRow
v-if="builds.suppItems"
label="Support"
:items="builds.suppItems"
:item-map="itemMap"
:total-count="builds.tree.count"
/>
<!-- Boots (regular or rush) -->
<ItemRow
:label="builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="builds.boots.slice(0, 2)"
:item-map="itemMap"
:total-count="builds.tree.count"
:max-items="2"
/>
</div>
<!-- Core Items Tree -->
<div class="item-row">
<span class="item-row-label">Core</span>
<ItemTree :tree="builds.tree" />
</div>
<!-- Late Game -->
<ItemRow
label="Late Game"
:items="builds.lateGame.slice(0, 6)"
:item-map="itemMap"
:total-count="builds.tree.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;
}
}
</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">
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,32 @@
/**
* Composable for managing build data with automatic trimming
* Handles deep cloning and tree manipulation
*/
import { deepClone } from '~/utils/helpers'
import { trimBuilds, trimLateGameItems } from '~/utils/buildHelpers'
export const useBuilds = (buildsProp: Ref<Builds>) => {
const builds = ref<Builds>(deepClone(buildsProp.value))
function trimBuildData(): void {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
}
// Watch for changes and rebuild
watch(
() => buildsProp.value,
newBuilds => {
builds.value = deepClone(newBuilds)
trimBuildData()
},
{ deep: true }
)
// Initial trim on mount
onMounted(() => {
trimBuildData()
})
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,97 @@
/**
* Composable for fetching and managing rune styles and keystones
* Transforms rune data into format needed for display components
*/
export const useRuneStyles = (
runes: Ref<
Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
>
) => {
const primaryStyles = ref<Array<PerkStyle>>(Array(runes.value.length))
const secondaryStyles = ref<Array<PerkStyle>>(Array(runes.value.length))
const keystoneIds = ref<Array<number>>(Array(runes.value.length))
const { data: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
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 }
)
function refreshStylesKeystones(): void {
if (!stylesData.value?.styles) return
primaryStyles.value = Array(runes.value.length)
secondaryStyles.value = Array(runes.value.length)
keystoneIds.value = Array(runes.value.length)
for (const style of stylesData.value.styles) {
for (const rune of runes.value) {
const runeIndex = runes.value.indexOf(rune)
if (style.id === rune.primaryStyle) {
primaryStyles.value[runeIndex] = style
// Find keystone from first slot
if (style.slots?.[0]?.perks) {
for (const perk of style.slots[0].perks) {
if (rune.selections.includes(perk)) {
keystoneIds.value[runeIndex] = perk
break
}
}
}
}
if (style.id === rune.secondaryStyle) {
secondaryStyles.value[runeIndex] = style
}
}
}
}
// Refresh when styles data loads or runes change
watch(
[stylesData, runes],
() => {
refreshStylesKeystones()
},
{ immediate: true }
)
// Reset when runes array changes
watch(
() => runes.value.length,
() => {
primaryStyles.value = Array(runes.value.length)
secondaryStyles.value = Array(runes.value.length)
keystoneIds.value = Array(runes.value.length)
refreshStylesKeystones()
}
)
return {
perks,
primaryStyles,
secondaryStyles,
keystoneIds
}
}

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,7 @@ 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')
// Use useAsyncData with client-side fetching for faster initial page load
const {
@@ -165,16 +165,10 @@ function fetchChampionData() {
:game-count="lane.count || 0"
/>
<ClientOnly>
<LazyRuneSelector
v-if="state == 'runes' && championData.gameCount > 0 && lane?.runes"
<LazyBuildViewer
v-if="state == 'build' && championData.gameCount > 0 && lane?.runes && lane?.builds"
style="margin: auto; margin-top: 40px"
:runes="lane.runes"
/>
</ClientOnly>
<ClientOnly>
<LazyItemViewer
v-if="state == 'items' && championData.gameCount > 0 && lane?.builds"
style="margin: auto; margin-top: 40px"
:builds="lane.builds"
/>
</ClientOnly>
@@ -216,8 +210,10 @@ function fetchChampionData() {
}
#champion-content {
margin-top: 64px;
margin-left: 39px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* Loading state styles */

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

@@ -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,64 @@
import { isEmpty } from './helpers'
/**
* Trims the build tree to only show the first path
* Removes alternate build paths to keep the UI clean
*/
export 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)
// Also trim grandchildren to first path only
if (builds.tree.children[0]?.children) {
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
}
/**
* Removes late game items that appear in the core build tree
* Prevents duplicate items from being shown
*/
export function trimLateGameItems(builds: Builds): void {
if (!builds?.tree || isEmpty(builds.lateGame)) return
const coreItemIds = new Set<number>()
// Collect all item IDs from the tree
function collectItemIds(tree: ItemTree): void {
if (tree.data !== undefined) {
coreItemIds.add(tree.data)
}
for (const child of tree.children || []) {
collectItemIds(child)
}
}
collectItemIds(builds.tree)
// Remove late game items that appear in core
builds.lateGame = builds.lateGame.filter(item => !coreItemIds.has(item.data))
}
/**
* Gets the index of the build with the highest pickrate
*/
export function getHighestPickrateBuildIndex(runes: Array<{ pickrate: number }>): number {
if (runes.length === 0) return 0
return runes.reduce(
(maxIdx, rune, idx, arr) => (rune.pickrate > arr[maxIdx].pickrate ? idx : maxIdx),
0
)
}
/**
* Gets the first core item for each build variant
*/
export function getFirstCoreItems(runes: unknown[], builds: Builds): number[] {
return runes.map(() => {
const tree = builds?.tree
return tree?.children?.[0]?.data ?? tree?.data ?? 0
})
}

View File

@@ -0,0 +1,11 @@
/**
* Mock data for development and fallback scenarios
* Used when API data is not available
*/
export const MOCK_SUMMONER_SPELLS = [
{ id: 4, count: 1000, pickrate: 0.45 }, // Flash
{ id: 7, count: 800, pickrate: 0.35 }, // Heal
{ id: 14, count: 600, pickrate: 0.15 }, // Ignite
{ id: 3, count: 200, pickrate: 0.05 } // Exhaust
]

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!')
}