Files
buildpath/frontend/components/build/Viewer.vue

428 lines
10 KiB
Vue

<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers'
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 }> // API data when available
}>()
// State
const currentlySelectedBuild = ref(0)
// Fetch items from cached API
const { data: items } = useFetch<Array<Item>>('/api/cdragon/items', {
lazy: true,
server: false
})
const itemMap = ref<Map<number, Item>>(new Map())
watch(
items,
newItems => {
if (Array.isArray(newItems)) {
const map = new Map<number, Item>()
for (const item of newItems) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
},
{ immediate: true }
)
// Fetch summoner spells from cached API
const { data: summonerSpellsData } = useFetch<Array<SummonerSpell>>(
'/api/cdragon/summoner-spells',
{
lazy: true,
server: false
}
)
const summonerSpellMap = ref<Map<number, SummonerSpell>>(new Map())
watch(
summonerSpellsData,
newData => {
if (Array.isArray(newData)) {
const map = new Map<number, SummonerSpell>()
for (const spell of newData) {
if (spell?.id) {
map.set(spell.id, spell)
}
}
summonerSpellMap.value = map
}
},
{ immediate: true }
)
// Mock summoner spells data if not provided by API
const mockSummonerSpells = computed(() => {
if (props.summonerSpells && props.summonerSpells.length > 0) {
return props.summonerSpells
}
// Default mock data based on common summoner spells
return [
{ 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
]
})
// Builds management
const builds = ref<Builds>(deepClone(props.builds))
watch(
() => props.builds,
newBuilds => {
builds.value = deepClone(newBuilds)
trimBuilds(builds.value)
trimLateGameItems(builds.value)
},
{ deep: true }
)
onMounted(() => {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
})
function trimBuilds(builds: Builds): void {
if (!builds?.tree?.children) return
builds.tree.children.splice(1, builds.tree.children.length - 1)
if (builds.tree.children[0]?.children) {
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
}
function trimLateGameItems(builds: Builds): void {
if (!builds?.tree || isEmpty(builds.lateGame)) return
function trimLateGameItemsFromTree(tree: ItemTree): void {
const foundIndex = builds.lateGame.findIndex(x => x.data === tree.data)
if (foundIndex !== -1) {
builds.lateGame.splice(foundIndex, 1)
}
for (const child of tree.children || []) {
trimLateGameItemsFromTree(child)
}
}
trimLateGameItemsFromTree(builds.tree)
}
// Get first core item for build variant display
const firstCoreItems = computed(() => {
const result: number[] = []
for (let i = 0; i < props.runes.length; i++) {
const tree = builds.value?.tree
if (tree?.children?.[0]?.data) {
result.push(tree.children[0].data)
} else if (tree?.data) {
result.push(tree.data)
} else {
result.push(0)
}
}
return result
})
// Get the highest pickrate rune build
const highestPickrateBuildIndex = computed(() => {
if (props.runes.length === 0) return 0
let maxIndex = 0
let maxPickrate = props.runes[0].pickrate
for (let i = 1; i < props.runes.length; i++) {
if (props.runes[i].pickrate > maxPickrate) {
maxPickrate = props.runes[i].pickrate
maxIndex = i
}
}
return maxIndex
})
// State for compact rune selector
const selectedRuneIndex = ref(0)
watch(
() => currentlySelectedBuild,
() => {
selectedRuneIndex.value = 0
}
)
function selectRune(index: number) {
selectedRuneIndex.value = index
currentlySelectedBuild.value = index
}
// Rune styles
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const keystoneIds: Ref<Array<number>> = ref(Array(props.runes.length))
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
const perks = reactive(new Map())
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
function refreshStylesKeystones() {
for (const style of stylesData.value.styles) {
for (const rune of props.runes) {
if (style.id == rune.primaryStyle) {
primaryStyles.value[props.runes.indexOf(rune)] = style
for (const perk of style.slots[0].perks) {
if (rune.selections.includes(perk)) {
keystoneIds.value[props.runes.indexOf(rune)] = perk
}
}
}
if (style.id == rune.secondaryStyle) {
secondaryStyles.value[props.runes.indexOf(rune)] = style
}
}
}
}
watch(
() => props.runes,
() => {
currentlySelectedBuild.value = 0
primaryStyles.value = Array(props.runes.length)
secondaryStyles.value = Array(props.runes.length)
keystoneIds.value = Array(props.runes.length)
refreshStylesKeystones()
}
)
refreshStylesKeystones()
</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"
/>
<!-- Main Build Content -->
<div class="build-content">
<!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column">
<!-- Summoner Spells -->
<SummonerSpells :spells="mockSummonerSpells" :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"
/>
</div>
<!-- Compact Rune Selector -->
<CompactRuneSelector
:runes="runes"
:primary-styles="primaryStyles"
:secondary-styles="secondaryStyles"
:keystone-ids="keystoneIds"
:perks="perks"
:selected-index="currentlySelectedBuild"
@select="selectRune"
/>
</div>
</div>
<!-- Right Column: Items -->
<div class="build-right-column">
<h3 class="section-title">Items</h3>
<!-- Start/Support + Boots Container -->
<div class="item-row-group">
<!-- Start Items -->
<ItemRow
v-if="!builds.suppItems"
label="Start"
:items="builds.start"
:item-map="itemMap"
:total-count="builds.tree.count"
/>
<!-- Support Items -->
<ItemRow
v-if="builds.suppItems"
label="Support"
:items="builds.suppItems"
:item-map="itemMap"
:total-count="builds.tree.count"
/>
<!-- Boots (regular or rush) -->
<ItemRow
:label="builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="builds.boots.slice(0, 2)"
:item-map="itemMap"
:total-count="builds.tree.count"
:max-items="2"
/>
</div>
<!-- Core Items Tree -->
<div class="item-row">
<span class="item-row-label">Core</span>
<ItemTree :tree="builds.tree" />
</div>
<!-- Late Game -->
<ItemRow
label="Late Game"
:items="builds.lateGame.slice(0, 6)"
:item-map="itemMap"
:total-count="builds.tree.count"
:max-items="6"
/>
</div>
</div>
</div>
</template>
<style>
.build-viewer {
width: 100%;
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
/* Main Build Content */
.build-content {
display: flex;
justify-content: center;
gap: 80px;
padding: 0 24px;
width: 100%;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: #4a9eff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Left Column: Runes + Summoner Spells */
.build-left-column {
display: flex;
flex-direction: column;
gap: 40px;
}
.rune-section {
display: flex;
flex-direction: column;
}
.rune-page-wrapper {
margin-top: -45px;
transform: scale(0.7);
transform-origin: center;
}
/* Right Column: Items */
.build-right-column {
display: flex;
flex-direction: column;
gap: 20px;
}
.item-row-group {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.item-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-row-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-on-surface);
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.build-content {
flex-direction: column;
gap: 40px;
align-items: center;
}
.build-left-column {
order: 1;
align-items: center;
}
.build-right-column {
order: 2;
align-items: center;
}
.section-title {
font-size: 0.9rem;
}
.rune-section,
.summoner-spells-section {
align-items: center;
}
.rune-page-wrapper {
transform: scale(1);
transform-origin: center center;
}
}
</style>