Runes
@@ -105,46 +94,47 @@ function selectRune(index: number): void {
-
+
-
-
diff --git a/frontend/components/nav/SideBar.vue b/frontend/components/nav/SideBar.vue
index a34f765..7924dce 100644
--- a/frontend/components/nav/SideBar.vue
+++ b/frontend/components/nav/SideBar.vue
@@ -137,7 +137,7 @@ if (route.path.startsWith('/tierlist/')) {
Loading stats...
About
-
+
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,
diff --git a/frontend/composables/useBuilds.ts b/frontend/composables/useBuilds.ts
index abeadba..1e4dfbe 100644
--- a/frontend/composables/useBuilds.ts
+++ b/frontend/composables/useBuilds.ts
@@ -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) => {
const builds = ref(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 }
}
diff --git a/frontend/composables/useRuneStyles.ts b/frontend/composables/useRuneStyles.ts
index 36ff172..02d3848 100644
--- a/frontend/composables/useRuneStyles.ts
+++ b/frontend/composables/useRuneStyles.ts
@@ -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
- pickrate: number
- }>
- >
-) => {
- const primaryStyles = ref>(Array(runes.value.length))
- const secondaryStyles = ref>(Array(runes.value.length))
- const keystoneIds = ref>(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())
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())
+ 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
}
}
diff --git a/frontend/pages/champion/[alias].vue b/frontend/pages/champion/[alias].vue
index 754124f..421be16 100644
--- a/frontend/pages/champion/[alias].vue
+++ b/frontend/pages/champion/[alias].vue
@@ -10,6 +10,13 @@ const error = ref(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() {
/>
-
+
+
-
+
- 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
+
/**
* 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[]
}
diff --git a/frontend/utils/buildHelpers.ts b/frontend/utils/buildHelpers.ts
index 1440c03..e4c96e5 100644
--- a/frontend/utils/buildHelpers.ts
+++ b/frontend/utils/buildHelpers.ts
@@ -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()
- // 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 {
+ const arr: Array = []
+
+ 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()
-
- // 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
}
diff --git a/frontend/utils/mockData.ts b/frontend/utils/mockData.ts
deleted file mode 100644
index 7340bfd..0000000
--- a/frontend/utils/mockData.ts
+++ /dev/null
@@ -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
-]
diff --git a/match_collector/champion_stat.ts b/match_collector/champion_stat.ts
index fc5f8bb..b6c292f 100644
--- a/match_collector/champion_stat.ts
+++ b/match_collector/champion_stat.ts
@@ -7,7 +7,15 @@ function sameArrays(array1: Array, array2: Array) {
}
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
pickrate?: number
}
-type Builds = {
- tree: ItemTree
- start: Array<{ data: number; count: number }>
- bootsFirst: number
+type Build = {
+ runeKeystone: number
+ runes: Array
+ 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
+ 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
builds: Builds
matchups?: Array
+ summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
}
type ChampionData = {
champion: Champion
@@ -80,8 +106,9 @@ type ChampionData = {
lanes: Array
}
+// Helper function to create rune configuration from participant
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function handleParticipantRunes(participant: any, runes: Array) {
+function createRuneConfiguration(participant: any): Rune {
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
const selections: Array = []
@@ -90,28 +117,56 @@ function handleParticipantRunes(participant: any, runes: Array) {
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 = []
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) {
// 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) {
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) {
}
}
- // 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()
+
+ 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()
+ 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()
+ 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) {
diff --git a/match_collector/item_tree.ts b/match_collector/item_tree.ts
index 46ff119..f527b00 100644
--- a/match_collector/item_tree.ts
+++ b/match_collector/item_tree.ts
@@ -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 {
+ const items: Set = 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()
+ for (const item of Array.from(set1)) {
+ if (set2.has(item)) {
+ intersection.add(item)
+ }
+ }
+
+ // Calculate union
+ const union = new Set()
+ 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 }