frontend: refactor build viewer a bit more

This commit is contained in:
2026-02-28 13:38:14 +01:00
parent 7833780bcb
commit c362d6b12a
5 changed files with 62 additions and 530 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { deepClone } from '~/utils/helpers' import { getHighestPickrateBuildIndex, getFirstCoreItems } from '~/utils/buildHelpers'
import { trimBuilds, trimLateGameItems, getHighestPickrateBuildIndex, getFirstCoreItems } from '~/utils/buildHelpers' import { MOCK_SUMMONER_SPELLS, BOOTS_RUSH_THRESHOLD } 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 SummonerSpells from '~/components/build/SummonerSpells.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue' import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
@@ -28,43 +28,25 @@ const { summonerSpellMap } = useSummonerSpellMap()
// Use composable for rune styles // Use composable for rune styles
const { perks, primaryStyles, secondaryStyles, keystoneIds } = useRuneStyles(toRef(props, 'runes')) const { perks, primaryStyles, secondaryStyles, keystoneIds } = useRuneStyles(toRef(props, 'runes'))
// Mock summoner spells data if not provided by API // Use composable for builds management
const mockSummonerSpells = computed(() => { const { builds } = useBuilds(toRef(props, 'builds'))
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 // Summoner spells data - use提供的 or fall back to mock
const builds = ref<Builds>(deepClone(props.builds)) const displaySummonerSpells = computed(() =>
(props.summonerSpells && props.summonerSpells.length > 0)
watch( ? props.summonerSpells
() => props.builds, : MOCK_SUMMONER_SPELLS
(newBuilds) => {
builds.value = deepClone(newBuilds)
trimBuilds(builds.value)
trimLateGameItems(builds.value)
},
{ deep: true }
) )
onMounted(() => {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
})
// Computed properties using utility functions // Computed properties using utility functions
const firstCoreItems = computed(() => getFirstCoreItems(props.runes, builds.value)) const firstCoreItems = computed(() => getFirstCoreItems(props.runes, builds.value))
const highestPickrateBuildIndex = computed(() => getHighestPickrateBuildIndex(props.runes)) const highestPickrateBuildIndex = computed(() => getHighestPickrateBuildIndex(props.runes))
const bootsLabel = computed(() =>
builds.value.bootsFirst > BOOTS_RUSH_THRESHOLD ? 'Boots Rush' : 'Boots'
)
// Reset selected build when runes change // Reset selected build when runes change
watch( watch(
() => props.runes, () => props.runes,
@@ -94,7 +76,7 @@ function selectRune(index: number): void {
<!-- Left Column: Summoner Spells + Runes --> <!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column"> <div class="build-left-column">
<!-- Summoner Spells --> <!-- Summoner Spells -->
<SummonerSpells :spells="mockSummonerSpells" :summoner-spell-map="summonerSpellMap" /> <SummonerSpells :spells="displaySummonerSpells" :summoner-spell-map="summonerSpellMap" />
<!-- Rune Page --> <!-- Rune Page -->
<div class="rune-section"> <div class="rune-section">

View File

@@ -1,346 +0,0 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers'
const props = defineProps<{
builds: Builds
loading?: boolean
error?: boolean
}>()
// State - use cached API endpoint instead of direct CDragon fetch
const {
data: items,
pending: loadingItems,
error: itemsError
} = useFetch('/api/cdragon/items', {
lazy: true, // Don't block rendering
server: false // Client-side only
})
const itemMap = ref<Map<number, unknown>>(new Map())
// Initialize item map
watch(
items,
newItems => {
try {
const itemsData = newItems || []
if (Array.isArray(itemsData)) {
const map = new Map<number, unknown>()
for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
} catch (error) {
console.error('Error initializing item map:', error)
}
},
{ immediate: true }
)
// 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 }
)
// Initialize with trimmed builds
onMounted(() => {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
})
/**
* Trim builds tree to show only primary build paths
*/
function trimBuilds(builds: Builds): void {
if (!builds?.tree?.children) return
// Keep only the first child (primary build path)
builds.tree.children.splice(1, builds.tree.children.length - 1)
// For the primary path, keep only the first child of the first child
if (builds.tree.children[0]?.children) {
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
}
/**
* Remove items from lateGame that are already in the build tree
*/
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)
}
// Error and loading states
const _hasError = computed(() => itemsError.value || props.error)
const _isLoading = computed(() => loadingItems.value || props.loading)
</script>
<template>
<div id="iv-container">
<div>
<!-- Start items -->
<ItemBox v-if="builds.suppItems == undefined || builds.suppItems == null" title="start">
<div class="iv-items-container">
<div
v-for="item in builds.start"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Supp items -->
<ItemBox v-if="builds.suppItems != undefined && builds.suppItems != null" title="supp">
<div class="iv-items-container">
<div
v-for="item in builds.suppItems"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
</div>
<!-- Boots first : when champion rush boots -->
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :boots-first="builds.bootsFirst">
<div class="iv-items-container">
<div
v-for="item in builds.boots"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Core items -->
<ItemBox title="core">
<ItemTree style="margin: auto; width: fit-content" :tree="builds.tree" />
</ItemBox>
<!-- Boots -->
<ItemBox v-if="builds.bootsFirst <= 0.5" title="boots">
<div class="iv-items-container">
<div
v-for="item in builds.boots.slice(0, 4)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Late game items -->
<ItemBox title="late game">
<div id="iv-late-game-container">
<div class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(0, 4)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
<div v-if="builds.lateGame.length > 4" class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(4, 8)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</div>
</ItemBox>
</div>
</template>
<style>
#iv-container {
display: flex;
width: fit-content;
height: fit-content;
}
.iv-items-container {
display: flex;
flex-direction: column;
width: fit-content;
height: fit-content;
margin: auto;
}
.item-img {
border: 1px solid var(--color-on-surface);
margin: 10px;
}
#iv-late-game-container {
display: flex;
}
@media only screen and (max-width: 1000px) {
#iv-container {
flex-direction: column;
width: 100%;
}
.iv-items-container {
flex-direction: row;
}
.item-img {
width: 48px;
height: 48px;
}
#iv-late-game-container {
flex-direction: column;
}
}
</style>

View File

@@ -1,152 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
runes: Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
}>()
const currentlySelectedPage = ref(0)
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))
// Use cached API endpoints instead of direct CDragon fetch
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')
watch(
() => props.runes,
(_newRunes, _oldRunes) => {
currentlySelectedPage.value = 0
primaryStyles.value = Array(props.runes.length)
secondaryStyles.value = Array(props.runes.length)
keystoneIds.value = Array(props.runes.length)
refreshStylesKeystones()
}
)
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
}
}
}
}
refreshStylesKeystones()
function runeSelect(index: number) {
currentlySelectedPage.value = index
}
</script>
<template>
<div style="width: fit-content">
<RunePage
v-if="runes[currentlySelectedPage] != undefined && runes[currentlySelectedPage] != null"
style="margin: auto; width: fit-content"
:primary-style-id="runes[currentlySelectedPage].primaryStyle"
:secondary-style-id="runes[currentlySelectedPage].secondaryStyle"
:selection-ids="runes[currentlySelectedPage].selections"
/>
<div style="display: flex; margin-top: 20px; justify-content: center">
<div v-for="(_, i) in runes" :key="i" @click="runeSelect(i)">
<div
:class="
'rune-selector-entry ' +
(i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')
"
>
<div class="rs-styles-container">
<NuxtImg
v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)"
/>
<NuxtImg
v-if="keystoneIds[i] != null && keystoneIds[i] != undefined"
class="rs-style-img"
width="34"
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)"
/>
</div>
</div>
<h3 class="rs-pickrate">{{ (runes[i].pickrate * 100).toFixed(2) }}% pick.</h3>
</div>
</div>
</div>
</template>
<style>
.rune-selector-entry {
width: 200px;
height: 120px;
margin-left: 10px;
margin-right: 10px;
border-radius: 8%;
border: 1px solid var(--color-on-surface);
}
.rune-selector-entry:hover {
cursor: pointer;
}
.rune-selector-entry-selected {
background-color: var(--color-surface-darker);
}
.rs-styles-container {
display: flex;
margin-top: 20px;
}
.rs-pickrate {
text-align: center;
margin-top: -40px;
padding-bottom: 40px;
}
@media only screen and (max-width: 650px) {
.rune-selector-entry {
width: 100px;
height: 60px;
margin-left: 5px;
margin-right: 5px;
}
.rs-styles-container {
margin-top: 17px;
}
.rs-pickrate {
margin-top: 5px;
padding-bottom: 0px;
}
.rs-style-img {
width: 24px;
height: 24px;
}
}
</style>

View File

@@ -0,0 +1,32 @@
/**
* Composable for managing build data with automatic trimming
* Handles deep cloning and tree manipulation
*/
import { deepClone } from '~/utils/helpers'
import { trimBuilds, trimLateGameItems } from '~/utils/buildHelpers'
export const useBuilds = (buildsProp: Ref<Builds>) => {
const builds = ref<Builds>(deepClone(buildsProp.value))
function trimBuildData(): void {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
}
// Watch for changes and rebuild
watch(
() => buildsProp.value,
(newBuilds) => {
builds.value = deepClone(newBuilds)
trimBuildData()
},
{ deep: true }
)
// Initial trim on mount
onMounted(() => {
trimBuildData()
})
return { builds }
}

View File

@@ -0,0 +1,16 @@
/**
* 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
]
/**
* Constants used throughout the application
*/
export const BOOTS_RUSH_THRESHOLD = 0.5