Multiple changes

- backend: add summoner spells
- backend: add build variants
- backend: builds are now storing full tree with runes (keystones)
- backend: build trees are split on starter items and merged on runes
- frontend: computing core tree now
- frontend: variant selectors
This commit is contained in:
2026-03-06 23:33:02 +01:00
parent 930cbf5a18
commit 271c2b26d8
14 changed files with 684 additions and 373 deletions

View File

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

View File

@@ -1,23 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon' import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface SummonerSpellData { const { summonerSpellMap } = useSummonerSpellMap()
id: number
count: number
pickrate: number
}
const props = defineProps<{ defineProps<{
spells: Array<SummonerSpellData> summonerSpells: Array<{ id: number; count: number; pickrate: number }>
summonerSpellMap: Map<number, SummonerSpell>
}>() }>()
</script> </script>
<template> <template>
<div class="summoner-spells-section"> <div class="summoner-spells-section">
<h3 class="section-title">Summoner Spells</h3>
<div class="summoner-spells-row"> <div class="summoner-spells-row">
<div v-for="(spell, i) in props.spells.slice(0, 2)" :key="i" class="summoner-spell-item"> <div v-for="(spell, i) in summonerSpells" :key="i" class="summoner-spell-item">
<NuxtImg <NuxtImg
v-if="summonerSpellMap.get(spell.id)" v-if="summonerSpellMap.get(spell.id)"
class="summoner-spell-img" class="summoner-spell-img"
@@ -33,27 +27,21 @@ const props = defineProps<{
<style scoped> <style scoped>
.summoner-spells-section { .summoner-spells-section {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: #4a9eff;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.summoner-spells-row { .summoner-spells-row {
display: flex; display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.summoner-spell-item { .summoner-spell-item {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 2px;
} }
.summoner-spell-img { .summoner-spell-img {
@@ -72,21 +60,17 @@ const props = defineProps<{
} }
.spell-pickrate { .spell-pickrate {
font-size: 0.7rem; font-size: 0.65rem;
color: var(--color-on-surface); color: var(--color-on-surface);
opacity: 0.7; opacity: 0.7;
} }
/* Responsive: Mobile */ /* Responsive: Mobile */
@media only screen and (max-width: 900px) { @media only screen and (max-width: 900px) {
.section-title {
font-size: 0.9rem;
}
.summoner-spell-img, .summoner-spell-img,
.summoner-spell-placeholder { .summoner-spell-placeholder {
width: 36px; width: 32px;
height: 36px; height: 32px;
} }
} }
</style> </style>

View File

@@ -7,12 +7,18 @@ const props = defineProps<{
keystore: Map<number, Perk> keystore: Map<number, Perk>
itemMap: Map<number, Item> itemMap: Map<number, Item>
pickrate: number pickrate: number
selected: boolean
index: number
}>()
const emit = defineEmits<{
select: [index: number]
}>() }>()
</script> </script>
<template> <template>
<div class="build-variant-selector"> <div class="build-variant-selector">
<div :class="['build-variant-card', { selected: true }]"> <div :class="['build-variant-card', { selected }]" @click="emit('select', index)">
<div class="variant-content"> <div class="variant-content">
<!-- Keystone --> <!-- Keystone -->
<NuxtImg <NuxtImg

View File

@@ -9,23 +9,22 @@ interface RuneBuild {
pickrate: number pickrate: number
} }
interface PerkStyle {
id: number
iconPath: string
}
const props = defineProps<{ const props = defineProps<{
runes: Array<RuneBuild> runes: Array<RuneBuild>
primaryStyles: Array<PerkStyle>
secondaryStyles: Array<PerkStyle>
keystoneIds: Array<number>
perks: Map<number, Perk> perks: Map<number, Perk>
selectedIndex: number perkStyles: Map<number, PerkStyle>
}>() }>()
const selectedIndex = ref(0)
const emit = defineEmits<{ const emit = defineEmits<{
select: [index: number] select: [index: number]
}>() }>()
function select(index: number) {
emit('select', index)
selectedIndex.value = index
}
</script> </script>
<template> <template>
@@ -33,24 +32,24 @@ const emit = defineEmits<{
<div <div
v-for="(rune, index) in props.runes" v-for="(rune, index) in props.runes"
:key="index" :key="index"
:class="['compact-rune-option', { active: index === props.selectedIndex }]" :class="['compact-rune-option', { active: index === selectedIndex }]"
@click="emit('select', index)" @click="select(index)"
> >
<div class="compact-rune-content"> <div class="compact-rune-content">
<NuxtImg <NuxtImg
v-if="primaryStyles[index]" v-if="runes[index].primaryStyle"
class="compact-rune-img" class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(primaryStyles[index].iconPath)" :src="CDRAGON_BASE + mapPath(perkStyles.get(runes[index].primaryStyle)?.iconPath!)"
/> />
<NuxtImg <NuxtImg
v-if="keystoneIds[index] && props.perks.get(keystoneIds[index])" v-if="perks.get(runes[index].selections[0])"
class="compact-rune-img" class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(props.perks.get(keystoneIds[index])!.iconPath)" :src="CDRAGON_BASE + mapPath(perks.get(runes[index].selections[0])!.iconPath)"
/> />
<NuxtImg <NuxtImg
v-if="secondaryStyles[index]" v-if="runes[index].secondaryStyle"
class="compact-rune-img" class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(secondaryStyles[index].iconPath)" :src="CDRAGON_BASE + mapPath(perkStyles.get(runes[index].secondaryStyle)?.iconPath!)"
/> />
</div> </div>
<span class="compact-rune-pickrate">{{ (rune.pickrate * 100).toFixed(1) }}%</span> <span class="compact-rune-pickrate">{{ (rune.pickrate * 100).toFixed(1) }}%</span>

View File

@@ -1,21 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { getHighestPickrateBuildIndex, getFirstCoreItems } from '~/utils/buildHelpers' import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers'
import { MOCK_SUMMONER_SPELLS } from '~/utils/mockData'
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue' import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
import SummonerSpells from '~/components/build/SummonerSpells.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue' import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
import ItemRow from '~/components/build/ItemRow.vue' import ItemRow from '~/components/build/ItemRow.vue'
const props = defineProps<{ const props = defineProps<{
runes: Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
builds: Builds builds: Builds
summonerSpells?: Array<{ id: number; count: number; pickrate: number }>
}>() }>()
// State // State
@@ -23,76 +13,75 @@ const currentlySelectedBuild = ref(0)
// Use composables for data fetching // Use composables for data fetching
const { itemMap } = useItemMap() const { itemMap } = useItemMap()
const { summonerSpellMap } = useSummonerSpellMap() const { perks, perkStyles } = useRuneStyles()
// Use composable for rune styles
const { perks, primaryStyles, secondaryStyles, keystoneIds } = useRuneStyles(toRef(props, 'runes'))
// Use composable for builds management // Use composable for builds management
const { builds } = useBuilds(toRef(props, 'builds')) const { builds } = useBuilds(toRef(props, 'builds'))
const currentBuild = computed(() => builds.value[currentlySelectedBuild.value])
// Summoner spells data - use提供的 or fall back to mock // Late game items for current build
const displaySummonerSpells = computed(() => const lateGameItems = computed(() => {
props.summonerSpells && props.summonerSpells.length > 0 if (!currentBuild.value) return []
? props.summonerSpells return getLateGameItems(currentBuild.value).slice(0, 6)
: MOCK_SUMMONER_SPELLS })
)
// Computed properties using utility functions const currentlySelectedRunes = ref(0)
const firstCoreItems = computed(() => getFirstCoreItems(props.runes, builds.value))
const highestPickrateBuildIndex = computed(() => getHighestPickrateBuildIndex(props.runes)) // Reset selected build when variant changes
// Reset selected build when runes change
watch( watch(
() => props.runes, () => currentBuild,
() => { () => {
currentlySelectedBuild.value = 0 currentlySelectedBuild.value = 0
currentlySelectedRunes.value = 0
} }
) )
function selectRune(index: number): void { function selectRune(index: number): void {
currentlySelectedRunes.value = index
}
function selectBuild(index: number): void {
currentlySelectedBuild.value = index currentlySelectedBuild.value = index
} }
</script> </script>
<template> <template>
<div class="build-viewer"> <div v-if="currentBuild" class="build-viewer">
<!-- Global Build Variant Selector - Single variant with highest pickrate --> <div style="display: flex">
<BuildVariantSelector <BuildVariantSelector
:keystone-id="keystoneIds[highestPickrateBuildIndex]" v-for="(build, i) in builds"
:item-id="firstCoreItems[highestPickrateBuildIndex]" :key="i"
:keystone-id="build.runeKeystone"
:item-id="build.items.children[0].data"
:keystore="perks" :keystore="perks"
:item-map="itemMap" :item-map="itemMap"
:pickrate="1" :pickrate="build.pickrate"
:selected="currentBuild == build"
:index="i"
@select="selectBuild"
/> />
</div>
<!-- Main Build Content --> <!-- Main Build Content -->
<div class="build-content"> <div class="build-content">
<!-- Left Column: Summoner Spells + Runes --> <!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column"> <div class="build-left-column">
<!-- Summoner Spells -->
<SummonerSpells :spells="displaySummonerSpells" :summoner-spell-map="summonerSpellMap" />
<!-- Rune Page --> <!-- Rune Page -->
<div class="rune-section"> <div class="rune-section">
<h3 class="section-title">Runes</h3> <h3 class="section-title">Runes</h3>
<div class="rune-page-wrapper"> <div class="rune-page-wrapper">
<RunePage <RunePage
v-if="runes[currentlySelectedBuild]" v-if="currentBuild.runes"
:primary-style-id="runes[currentlySelectedBuild].primaryStyle" :primary-style-id="currentBuild.runes[currentlySelectedRunes].primaryStyle"
:secondary-style-id="runes[currentlySelectedBuild].secondaryStyle" :secondary-style-id="currentBuild.runes[currentlySelectedRunes].secondaryStyle"
:selection-ids="runes[currentlySelectedBuild].selections" :selection-ids="currentBuild.runes[currentlySelectedRunes].selections"
/> />
</div> </div>
<!-- Compact Rune Selector --> <!-- Compact Rune Selector -->
<CompactRuneSelector <CompactRuneSelector
:runes="runes" :runes="currentBuild.runes"
:primary-styles="primaryStyles"
:secondary-styles="secondaryStyles"
:keystone-ids="keystoneIds"
:perks="perks" :perks="perks"
:perk-styles="perkStyles"
:selected-index="currentlySelectedBuild" :selected-index="currentlySelectedBuild"
@select="selectRune" @select="selectRune"
/> />
@@ -105,46 +94,47 @@ function selectRune(index: number): void {
<!-- Start/Support + Boots Container --> <!-- Start/Support + Boots Container -->
<div class="item-row-group"> <div class="item-row-group">
<!-- Start Items --> <!-- Start Item (root of the tree) -->
<ItemRow <ItemRow
v-if="!builds.suppItems" v-if="currentBuild.startItems && currentBuild.startItems.length > 0"
label="Start" label="Start"
:items="builds.start" :items="currentBuild.startItems"
:item-map="itemMap" :item-map="itemMap"
:total-count="builds.tree.count" :total-count="currentBuild.count"
/> />
<!-- Support Items --> <!-- Support Items -->
<ItemRow <ItemRow
v-if="builds.suppItems" v-if="currentBuild.suppItems && currentBuild.suppItems.length > 0"
label="Support" label="Support"
:items="builds.suppItems" :items="currentBuild.suppItems"
:item-map="itemMap" :item-map="itemMap"
:total-count="builds.tree.count" :total-count="currentBuild.count"
/> />
<!-- Boots (regular or rush) --> <!-- Boots (regular or rush) -->
<ItemRow <ItemRow
:label="builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'" :label="currentBuild.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="builds.boots.slice(0, 2)" :items="currentBuild.boots.slice(0, 2)"
:item-map="itemMap" :item-map="itemMap"
:total-count="builds.tree.count" :total-count="currentBuild.count"
:max-items="2" :max-items="2"
/> />
</div> </div>
<!-- Core Items Tree --> <!-- Core Items Tree (children of start item) -->
<div class="item-row"> <div v-if="currentBuild.items?.children?.length" class="item-row">
<span class="item-row-label">Core</span> <span class="item-row-label">Core</span>
<ItemTree :tree="builds.tree" /> <ItemTree :tree="getCoreItems(currentBuild)" />
</div> </div>
<!-- Late Game --> <!-- Late Game -->
<ItemRow <ItemRow
v-if="lateGameItems.length > 0"
label="Late Game" label="Late Game"
:items="builds.lateGame.slice(0, 6)" :items="lateGameItems"
:item-map="itemMap" :item-map="itemMap"
:total-count="builds.tree.count" :total-count="currentBuild.count"
:max-items="6" :max-items="6"
/> />
</div> </div>

View File

@@ -137,7 +137,7 @@ if (route.path.startsWith('/tierlist/')) {
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3> <h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
</template> </template>
<NuxtLink to="/about"><h3>About</h3></NuxtLink> <NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px"> <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 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 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, Games, and all associated properties are trademarks or registered trademarks of Riot Games,

View File

@@ -1,32 +1,19 @@
/** /**
* Composable for managing build data with automatic trimming * Composable for managing build data
* Handles deep cloning and tree manipulation
*/ */
import { deepClone } from '~/utils/helpers' import { deepClone } from '~/utils/helpers'
import { trimBuilds, trimLateGameItems } from '~/utils/buildHelpers'
export const useBuilds = (buildsProp: Ref<Builds>) => { export const useBuilds = (buildsProp: Ref<Builds>) => {
const builds = ref<Builds>(deepClone(buildsProp.value)) const builds = ref<Builds>(deepClone(buildsProp.value))
function trimBuildData(): void {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
}
// Watch for changes and rebuild // Watch for changes and rebuild
watch( watch(
() => buildsProp.value, () => buildsProp.value,
newBuilds => { newBuilds => {
builds.value = deepClone(newBuilds) builds.value = deepClone(newBuilds)
trimBuildData()
}, },
{ deep: true } { deep: true }
) )
// Initial trim on mount
onMounted(() => {
trimBuildData()
})
return { builds } return { builds }
} }

View File

@@ -2,23 +2,10 @@
* Composable for fetching and managing rune styles and keystones * Composable for fetching and managing rune styles and keystones
* Transforms rune data into format needed for display components * Transforms rune data into format needed for display components
*/ */
export const useRuneStyles = ( 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: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles') const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
console.log(stylesData.value)
const perks = reactive(new Map<number, Perk>()) const perks = reactive(new Map<number, Perk>())
watch( watch(
@@ -36,62 +23,24 @@ export const useRuneStyles = (
{ immediate: true } { immediate: true }
) )
function refreshStylesKeystones(): void { const perkStyles = reactive(new Map<number, PerkStyle>())
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( watch(
[stylesData, runes], stylesData,
() => { newPerkStyles => {
refreshStylesKeystones() if (Array.isArray(newPerkStyles?.styles)) {
perkStyles.clear()
for (const perkStyle of newPerkStyles.styles) {
if (perkStyle?.id) {
perkStyles.set(perkStyle.id, perkStyle)
}
}
}
}, },
{ immediate: true } { 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 { return {
perks, perks,
primaryStyles, perkStyles
secondaryStyles,
keystoneIds
} }
} }

View File

@@ -10,6 +10,13 @@ const error = ref<string | null>(null)
const laneState = ref(0) const laneState = ref(0)
const state = ref('build') 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 // Use useAsyncData with client-side fetching for faster initial page load
const { const {
data: championData, data: championData,
@@ -156,6 +163,7 @@ function fetchChampionData() {
/> />
<div id="champion-content"> <div id="champion-content">
<div class="champion-header">
<ChampionTitle <ChampionTitle
v-if="championData.gameCount > 0 && lane" v-if="championData.gameCount > 0 && lane"
id="champion-title" id="champion-title"
@@ -164,20 +172,48 @@ function fetchChampionData() {
:pickrate="lane.pickrate || 0" :pickrate="lane.pickrate || 0"
:game-count="lane.count || 0" :game-count="lane.count || 0"
/> />
<SummonerSpells v-if="lane" :summoner-spells="lane.summonerSpells" />
</div>
<ClientOnly> <ClientOnly>
<LazyBuildViewer <LazyBuildViewer
v-if="state == 'build' && championData.gameCount > 0 && lane?.runes && lane?.builds" v-if="state == 'build' && championData.gameCount > 0 && lane?.builds"
style="margin: auto; margin-top: 40px" style="margin: auto; margin-top: 40px"
:runes="lane.runes"
:builds="lane.builds" :builds="lane.builds"
/> />
</ClientOnly> </ClientOnly>
<ClientOnly> <ClientOnly>
<LazyItemTree <div
v-if="state == 'alternatives' && championData.gameCount > 0 && lane?.builds?.tree" v-if="state == 'alternatives' && championData.gameCount > 0 && lane && lane.builds"
style="margin: auto; margin-top: 40px; width: fit-content" style="
:tree="lane.builds.tree" 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>
<ClientOnly> <ClientOnly>
<LazyMatchupSection <LazyMatchupSection
@@ -292,6 +328,15 @@ function fetchChampionData() {
background-color: #45a049; 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) { @media only screen and (max-width: 650px) {
#champion-content { #champion-content {
margin: auto; margin: auto;
@@ -300,7 +345,18 @@ function fetchChampionData() {
#champion-title { #champion-title {
margin: auto; margin: auto;
} }
.champion-header {
flex-direction: column;
gap: 20px;
} }
.layout-selector {
flex-wrap: wrap;
justify-content: center;
}
}
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
#alias-content-wrapper { #alias-content-wrapper {
flex-direction: column; flex-direction: column;

View File

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

View File

@@ -1,64 +1,89 @@
import { isEmpty } from './helpers'
/** /**
* Trims the build tree to only show the first path * Gets all late game items from the item tree (items beyond first level)
* Removes alternate build paths to keep the UI clean * Returns a flat array of unique items with their counts
*/ */
export function trimBuilds(builds: Builds): void { export function getLateGameItems(build: Build): Array<{ data: number; count: number }> {
if (!builds?.tree?.children) return const lateGameItems: Array<{ data: number; count: number }> = []
const itemCounts = new Map<number, number>()
// Keep only the first child (primary build path) // Collect late items
builds.tree.children.splice(1, builds.tree.children.length - 1) function collectLateItems(tree: ItemTree, depth: number = 0): void {
if (depth >= 3 && tree.data !== undefined && tree.count > 0) {
// Also trim grandchildren to first path only const existing = itemCounts.get(tree.data) || 0
if (builds.tree.children[0]?.children) { itemCounts.set(tree.data, existing + tree.count)
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
} }
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
} }
/** /**
* Removes late game items that appear in the core build tree * Creates a deep copy of an ItemTree trimmed to a maximum depth
* Prevents duplicate items from being shown * @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
*/ */
export function trimLateGameItems(builds: Builds): void { function trimTreeDepth(tree: ItemTree, maxDepth: number, currentDepth: number = 0): ItemTree {
if (!builds?.tree || isEmpty(builds.lateGame)) return const trimmedTree: ItemTree = {
count: tree.count,
const coreItemIds = new Set<number>() data: tree.data,
children: []
// Collect all item IDs from the tree
function collectItemIds(tree: ItemTree): void {
if (tree.data !== undefined) {
coreItemIds.add(tree.data)
} }
// If we haven't reached maxDepth, include children
if (currentDepth < maxDepth) {
for (const child of tree.children || []) { for (const child of tree.children || []) {
collectItemIds(child) trimmedTree.children.push(trimTreeDepth(child, maxDepth, currentDepth + 1))
} }
} }
collectItemIds(builds.tree) return trimmedTree
// Remove late game items that appear in core
builds.lateGame = builds.lateGame.filter(item => !coreItemIds.has(item.data))
} }
/** function trimTreeChildrensAtDepth(tree: ItemTree, maxChildren: number, depth: number) {
* Gets the index of the build with the highest pickrate if (depth == 0) {
*/ if (tree.children.length > maxChildren) {
export function getHighestPickrateBuildIndex(runes: Array<{ pickrate: number }>): number { tree.children.splice(maxChildren, tree.children.length - maxChildren)
if (runes.length === 0) return 0 }
return
return runes.reduce(
(maxIdx, rune, idx, arr) => (rune.pickrate > arr[maxIdx].pickrate ? idx : maxIdx),
0
)
} }
/** for (const c of tree.children) {
* Gets the first core item for each build variant trimTreeChildrensAtDepth(c, maxChildren, depth - 1)
*/ }
export function getFirstCoreItems(runes: unknown[], builds: Builds): number[] { }
return runes.map(() => {
const tree = builds?.tree export function getCoreItems(build: Build): ItemTree {
return tree?.children?.[0]?.data ?? tree?.data ?? 0 const tree = trimTreeDepth(build.items, 3)
}) trimTreeChildrensAtDepth(tree, 1, 0)
trimTreeChildrensAtDepth(tree, 1, 1)
trimTreeChildrensAtDepth(tree, 3, 2)
return tree
} }

View File

@@ -1,11 +0,0 @@
/**
* 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

@@ -7,7 +7,15 @@ function sameArrays(array1: Array<number>, array2: Array<number>) {
} }
import { MongoClient } from 'mongodb' 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() const itemDict = new Map()
async function itemList() { async function itemList() {
@@ -41,14 +49,32 @@ type Rune = {
selections: Array<number> selections: Array<number>
pickrate?: number pickrate?: number
} }
type Builds = { type Build = {
tree: ItemTree runeKeystone: number
start: Array<{ data: number; count: number }> runes: Array<Rune>
bootsFirst: number items: ItemTree
bootsFirstCount: number
bootsFirst?: number
count: number
suppItems: Array<{ data: number; count: number }>
boots: Array<{ data: number; count: number }> boots: Array<{ data: number; count: number }>
lateGame: Array<{ data: number; count: number }> pickrate?: number
suppItems?: Array<{ data: number; count: 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 = { type Champion = {
id: number id: number
name: string name: string
@@ -69,9 +95,9 @@ type LaneData = {
losingMatches: number losingMatches: number
winrate: number winrate: number
pickrate: number pickrate: number
runes: Array<Rune>
builds: Builds builds: Builds
matchups?: Array<MatchupData> matchups?: Array<MatchupData>
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
} }
type ChampionData = { type ChampionData = {
champion: Champion champion: Champion
@@ -80,8 +106,9 @@ type ChampionData = {
lanes: Array<LaneData> lanes: Array<LaneData>
} }
// Helper function to create rune configuration from participant
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = [] const selections: Array<number> = []
@@ -90,28 +117,56 @@ function handleParticipantRunes(participant: any, runes: Array<Rune>) {
selections.push(perk.perk) selections.push(perk.perk)
} }
} }
const gameRunes: Rune = { return {
count: 1, count: 0, // Will be incremented when added to build
primaryStyle: primaryStyle, primaryStyle: primaryStyle,
secondaryStyle: secondaryStyle, secondaryStyle: secondaryStyle,
selections: selections 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
timeline: any, timeline: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -119,6 +174,10 @@ function handleMatchItems(
participantIndex: number, participantIndex: number,
builds: Builds 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> = [] const items: Array<number> = []
for (const frame of timeline.info.frames) { for (const frame of timeline.info.frames) {
for (const event of frame.events) { for (const event of frame.events) {
@@ -145,8 +204,8 @@ function handleMatchItems(
x == participant.item6 x == participant.item6
) )
if (suppItem != undefined) { if (suppItem != undefined) {
const already = builds.suppItems.find(x => x.data == suppItem) const already = build.suppItems.find(x => x.data == suppItem)
if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem }) if (already == undefined) build.suppItems.push({ count: 1, data: suppItem })
else already.count += 1 else already.count += 1
} }
} }
@@ -166,12 +225,12 @@ function handleMatchItems(
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) { if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst // Check for bootsFirst
if (items.length < 2) { if (items.length < 2) {
builds.bootsFirst += 1 build.bootsFirstCount += 1
} }
// Add to boots // Add to boots array
const already = builds.boots.find(x => x.data == event.itemId) const already = build.boots.find(x => x.data == event.itemId)
if (already == undefined) builds.boots.push({ count: 1, data: event.itemId }) if (already == undefined) build.boots.push({ count: 1, data: event.itemId })
else already.count += 1 else already.count += 1
} }
@@ -188,28 +247,18 @@ function handleMatchItems(
// Ignore Cull as not-first item // Ignore Cull as not-first item
if (event.itemId == 1083 && items.length >= 1) continue if (event.itemId == 1083 && items.length >= 1) continue
// Ignore non-final items, except when first item bought // Ignore non-final items, except when first item bought or support role
if (itemInfo.to.length != 0 && items.length >= 1) continue if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY'))
continue
items.push(event.itemId) items.push(event.itemId)
} }
} }
// Core items // Merge the full item path into the build's item tree
treeMerge(builds.tree, items.slice(1, 4)) // This tree includes start item as the root, then branching paths
if (items.length > 0) {
// Start items treeMerge(build.items, 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
} }
} }
@@ -224,23 +273,15 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
// Lanes // Lanes
let lane = champion.lanes.find(x => x.data == participant.teamPosition) let lane = champion.lanes.find(x => x.data == participant.teamPosition)
if (lane == undefined) { if (lane == undefined) {
const builds: Builds = {
tree: treeInit(),
start: [],
bootsFirst: 0,
boots: [],
lateGame: [],
suppItems: []
}
lane = { lane = {
count: 1, count: 1,
data: participant.teamPosition, data: participant.teamPosition,
runes: [], builds: [],
builds: builds,
winningMatches: 0, winningMatches: 0,
losingMatches: 0, losingMatches: 0,
winrate: 0, winrate: 0,
pickrate: 0, pickrate: 0,
summonerSpells: [],
matchups: [] matchups: []
} }
champion.lanes.push(lane) champion.lanes.push(lane)
@@ -260,6 +301,18 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
lane.losingMatches++ 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 // Track counter matchups - find opponent in same lane
const opponentTeam = participant.teamId === 100 ? 200 : 100 const opponentTeam = participant.teamId === 100 ? 200 : 100
const opponent = match.info.participants.find( const opponent = match.info.participants.find(
@@ -292,11 +345,8 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
} }
} }
// Runes // Items and runes (builds)
handleParticipantRunes(participant, lane.runes) handleMatchBuilds(match.timeline, participant, participantIndex, lane.builds)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
} }
} }
@@ -322,50 +372,223 @@ async function handleMatchList(
return totalMatches 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) { async function finalizeChampionStats(champion: ChampionData, totalMatches: number) {
const totalChampionMatches = champion.winningMatches + champion.losingMatches const totalChampionMatches = champion.winningMatches + champion.losingMatches
arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2) arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2)
champion.lanes.sort((a, b) => b.count - a.count) champion.lanes.sort((a, b) => b.count - a.count)
// Filter runes to keep 3 most played
for (const lane of champion.lanes) { 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) // Cleaning up builds
if (runes.length > 3) runes.splice(3, runes.length - 3) cleanupLaneBuilds(lane)
// Compute runes pickrate
for (const rune of runes) rune.pickrate = rune.count / lane.count // 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)
} }
for (const lane of champion.lanes) { // Finally, we merge the builds that are similar but have different keystones.
const builds = lane.builds // 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
// Cut item tree branches to keep only 4 branches every time and with percentage threshold // all along.
builds.tree.count = lane.count lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
treeCutBranches(builds.tree, 4, 0.05) cleanupLaneBuilds(lane)
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)
} }
for (const lane of champion.lanes) { 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 }