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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
]
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user