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

View File

@@ -1,23 +1,17 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface SummonerSpellData {
id: number
count: number
pickrate: number
}
const { summonerSpellMap } = useSummonerSpellMap()
const props = defineProps<{
spells: Array<SummonerSpellData>
summonerSpellMap: Map<number, SummonerSpell>
defineProps<{
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
}>()
</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">
<div v-for="(spell, i) in summonerSpells" :key="i" class="summoner-spell-item">
<NuxtImg
v-if="summonerSpellMap.get(spell.id)"
class="summoner-spell-img"
@@ -33,27 +27,21 @@ const props = defineProps<{
<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;
flex-direction: row;
}
.summoner-spells-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.summoner-spell-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
gap: 2px;
}
.summoner-spell-img {
@@ -72,21 +60,17 @@ const props = defineProps<{
}
.spell-pickrate {
font-size: 0.7rem;
font-size: 0.65rem;
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;
width: 32px;
height: 32px;
}
}
</style>

View File

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

View File

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

View File

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

@@ -1,32 +1,19 @@
/**
* Composable for managing build data with automatic trimming
* Handles deep cloning and tree manipulation
* Composable for managing build data
*/
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

@@ -2,23 +2,10 @@
* 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))
export const useRuneStyles = () => {
const { data: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
console.log(stylesData.value)
const perks = reactive(new Map<number, Perk>())
watch(
@@ -36,62 +23,24 @@ export const useRuneStyles = (
{ 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
}
}
const perkStyles = reactive(new Map<number, PerkStyle>())
watch(
stylesData,
newPerkStyles => {
if (Array.isArray(newPerkStyles?.styles)) {
perkStyles.clear()
for (const perkStyle of newPerkStyles.styles) {
if (perkStyle?.id) {
perkStyles.set(perkStyle.id, perkStyle)
}
}
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
perkStyles
}
}

View File

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

View File

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

View File

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

View File

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

View File

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