Multiple changes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -137,7 +137,7 @@ if (route.path.startsWith('/tierlist/')) {
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
</template>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px">
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot Games,