frontend: refactor of the new build viewer
This commit is contained in:
151
frontend/components/build/BuildVariantSelector.vue
Normal file
151
frontend/components/build/BuildVariantSelector.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||
|
||||
const props = defineProps<{
|
||||
keystoneId: number
|
||||
itemId: number
|
||||
keystore: Map<number, Perk>
|
||||
itemMap: Map<number, Item>
|
||||
pickrate: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="build-variant-selector">
|
||||
<div :class="['build-variant-card', { selected: true }]">
|
||||
<div class="variant-content">
|
||||
<!-- Keystone -->
|
||||
<NuxtImg
|
||||
v-if="keystoneId && props.keystore.get(keystoneId)"
|
||||
class="variant-keystone"
|
||||
:src="CDRAGON_BASE + mapPath(props.keystore.get(keystoneId)!.iconPath)"
|
||||
/>
|
||||
<!-- First core item -->
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(itemId)"
|
||||
class="variant-item"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(itemId)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="variant-item-placeholder" />
|
||||
</div>
|
||||
<span class="variant-pickrate">{{ (pickrate * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.build-variant-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.build-variant-selector::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.build-variant-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-surface-darker);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.build-variant-card:hover {
|
||||
border-color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.build-variant-card.selected {
|
||||
border-color: #4a9eff;
|
||||
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
|
||||
}
|
||||
|
||||
.variant-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.variant-keystone {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.variant-item {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 184, 225, 0.3);
|
||||
}
|
||||
|
||||
.variant-item-placeholder {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 184, 225, 0.3);
|
||||
}
|
||||
|
||||
.variant-pickrate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-on-surface);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Responsive: Mobile */
|
||||
@media only screen and (max-width: 900px) {
|
||||
.build-variant-card {
|
||||
min-width: 70px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.variant-keystone,
|
||||
.variant-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.variant-item-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.variant-pickrate {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 400px) {
|
||||
.build-variant-selector {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.build-variant-card {
|
||||
min-width: 60px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.variant-keystone,
|
||||
.variant-item {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.variant-item-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
frontend/components/build/CompactRuneSelector.vue
Normal file
121
frontend/components/build/CompactRuneSelector.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||
|
||||
interface RuneBuild {
|
||||
count: number
|
||||
primaryStyle: number
|
||||
secondaryStyle: number
|
||||
selections: Array<number>
|
||||
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
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [index: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compact-rune-selector">
|
||||
<div
|
||||
v-for="(rune, index) in props.runes"
|
||||
:key="index"
|
||||
:class="['compact-rune-option', { active: index === props.selectedIndex }]"
|
||||
@click="emit('select', index)"
|
||||
>
|
||||
<div class="compact-rune-content">
|
||||
<NuxtImg
|
||||
v-if="primaryStyles[index]"
|
||||
class="compact-rune-img"
|
||||
:src="CDRAGON_BASE + mapPath(primaryStyles[index].iconPath)"
|
||||
/>
|
||||
<NuxtImg
|
||||
v-if="keystoneIds[index] && props.perks.get(keystoneIds[index])"
|
||||
class="compact-rune-img"
|
||||
:src="CDRAGON_BASE + mapPath(props.perks.get(keystoneIds[index])!.iconPath)"
|
||||
/>
|
||||
<NuxtImg
|
||||
v-if="secondaryStyles[index]"
|
||||
class="compact-rune-img"
|
||||
:src="CDRAGON_BASE + mapPath(secondaryStyles[index].iconPath)"
|
||||
/>
|
||||
</div>
|
||||
<span class="compact-rune-pickrate">{{ (rune.pickrate * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compact-rune-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: -45px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.compact-rune-selector::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact-rune-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.compact-rune-option:hover {
|
||||
border-color: var(--color-on-surface);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.compact-rune-option.active {
|
||||
border-color: #4a9eff;
|
||||
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
|
||||
}
|
||||
|
||||
.compact-rune-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.compact-rune-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.compact-rune-pickrate {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-on-surface);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.compact-rune-option.active .compact-rune-pickrate {
|
||||
opacity: 0.9;
|
||||
color: #4a9eff;
|
||||
}
|
||||
</style>
|
||||
99
frontend/components/build/ItemRow.vue
Normal file
99
frontend/components/build/ItemRow.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||
|
||||
interface ItemData {
|
||||
data: number
|
||||
count: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
items: Array<ItemData>
|
||||
itemMap: Map<number, Item>
|
||||
totalCount: number
|
||||
maxItems?: number
|
||||
}>()
|
||||
|
||||
const maxItems = computed(() => props.maxItems ?? props.items.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="item-row">
|
||||
<span class="item-row-label">{{ label }}</span>
|
||||
<div class="item-row-content">
|
||||
<div v-for="item in items.slice(0, maxItems)" :key="item.data" class="item-cell">
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="item-placeholder" />
|
||||
<span class="item-pickrate">{{ ((item.count / totalCount) * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
|
||||
.item-row-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.item-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.item-pickrate {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-on-surface);
|
||||
opacity: 0.6;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Responsive: Mobile */
|
||||
@media only screen and (max-width: 900px) {
|
||||
.item-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.item-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
frontend/components/build/SummonerSpells.vue
Normal file
92
frontend/components/build/SummonerSpells.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||
|
||||
interface SummonerSpellData {
|
||||
id: number
|
||||
count: number
|
||||
pickrate: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
spells: Array<SummonerSpellData>
|
||||
summonerSpellMap: Map<number, SummonerSpell>
|
||||
}>()
|
||||
</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">
|
||||
<NuxtImg
|
||||
v-if="summonerSpellMap.get(spell.id)"
|
||||
class="summoner-spell-img"
|
||||
:src="CDRAGON_BASE + mapPath(summonerSpellMap.get(spell.id)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="summoner-spell-placeholder" />
|
||||
<span class="spell-pickrate">{{ (spell.pickrate * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
.summoner-spells-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summoner-spell-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summoner-spell-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.summoner-spell-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.spell-pickrate {
|
||||
font-size: 0.7rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,9 @@
|
||||
<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<{
|
||||
@@ -136,6 +140,35 @@ const firstCoreItems = computed(() => {
|
||||
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))
|
||||
@@ -179,64 +212,25 @@ watch(
|
||||
)
|
||||
|
||||
refreshStylesKeystones()
|
||||
|
||||
function selectBuild(index: number) {
|
||||
currentlySelectedBuild.value = index
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="build-viewer">
|
||||
<!-- Global Build Variant Selector -->
|
||||
<div class="build-variant-selector">
|
||||
<div
|
||||
v-for="(_, i) in runes"
|
||||
:key="i"
|
||||
:class="['build-variant-card', { selected: i === currentlySelectedBuild }]"
|
||||
@click="selectBuild(i)"
|
||||
>
|
||||
<div class="variant-content">
|
||||
<!-- Keystone -->
|
||||
<NuxtImg
|
||||
v-if="keystoneIds[i] && perks.get(keystoneIds[i])"
|
||||
class="variant-keystone"
|
||||
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
|
||||
/>
|
||||
<!-- First core item -->
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(firstCoreItems[i])"
|
||||
class="variant-item"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(firstCoreItems[i])!.iconPath)"
|
||||
/>
|
||||
<div v-else class="variant-item-placeholder" />
|
||||
</div>
|
||||
<span class="variant-pickrate">{{ (runes[i].pickrate * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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 -->
|
||||
<div class="summoner-spells-section">
|
||||
<h3 class="section-title">Summoner Spells</h3>
|
||||
<div class="summoner-spells-row">
|
||||
<div
|
||||
v-for="(spell, i) in mockSummonerSpells.slice(0, 2)"
|
||||
:key="i"
|
||||
class="summoner-spell-item"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="summonerSpellMap.get(spell.id)"
|
||||
class="summoner-spell-img"
|
||||
:src="CDRAGON_BASE + mapPath(summonerSpellMap.get(spell.id)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="summoner-spell-placeholder" />
|
||||
<span class="spell-pickrate">{{ (spell.pickrate * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SummonerSpells :spells="mockSummonerSpells" :summoner-spell-map="summonerSpellMap" />
|
||||
|
||||
<!-- Rune Page -->
|
||||
<div class="rune-section">
|
||||
@@ -249,6 +243,17 @@ function selectBuild(index: number) {
|
||||
: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>
|
||||
|
||||
@@ -259,60 +264,31 @@ function selectBuild(index: number) {
|
||||
<!-- Start/Support + Boots Container -->
|
||||
<div class="item-row-group">
|
||||
<!-- Start Items -->
|
||||
<div v-if="!builds.suppItems" class="item-row">
|
||||
<span class="item-row-label">Start</span>
|
||||
<div class="item-row-content">
|
||||
<div v-for="item in builds.start" :key="item.data" class="item-cell">
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="item-placeholder" />
|
||||
<span class="item-pickrate"
|
||||
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ItemRow
|
||||
v-if="!builds.suppItems"
|
||||
label="Start"
|
||||
:items="builds.start"
|
||||
:item-map="itemMap"
|
||||
:total-count="builds.tree.count"
|
||||
/>
|
||||
|
||||
<!-- Support Items -->
|
||||
<div v-if="builds.suppItems" class="item-row">
|
||||
<span class="item-row-label">Support</span>
|
||||
<div class="item-row-content">
|
||||
<div v-for="item in builds.suppItems" :key="item.data" class="item-cell">
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="item-placeholder" />
|
||||
<span class="item-pickrate"
|
||||
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ItemRow
|
||||
v-if="builds.suppItems"
|
||||
label="Support"
|
||||
:items="builds.suppItems"
|
||||
:item-map="itemMap"
|
||||
:total-count="builds.tree.count"
|
||||
/>
|
||||
|
||||
<!-- Boots (regular or rush) -->
|
||||
<div class="item-row">
|
||||
<span class="item-row-label">{{
|
||||
builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'
|
||||
}}</span>
|
||||
<div class="item-row-content">
|
||||
<div v-for="item in builds.boots.slice(0, 2)" :key="item.data" class="item-cell">
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="item-placeholder" />
|
||||
<span class="item-pickrate"
|
||||
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 -->
|
||||
@@ -322,22 +298,13 @@ function selectBuild(index: number) {
|
||||
</div>
|
||||
|
||||
<!-- Late Game -->
|
||||
<div class="item-row">
|
||||
<span class="item-row-label">Late Game</span>
|
||||
<div class="item-row-content">
|
||||
<div v-for="item in builds.lateGame.slice(0, 6)" :key="item.data" class="item-cell">
|
||||
<NuxtImg
|
||||
v-if="itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
|
||||
/>
|
||||
<div v-else class="item-placeholder" />
|
||||
<span class="item-pickrate"
|
||||
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ItemRow
|
||||
label="Late Game"
|
||||
:items="builds.lateGame.slice(0, 6)"
|
||||
:item-map="itemMap"
|
||||
:total-count="builds.tree.count"
|
||||
:max-items="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,77 +320,6 @@ function selectBuild(index: number) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Build Variant Selector - Global */
|
||||
.build-variant-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.build-variant-selector::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.build-variant-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-surface-darker);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.build-variant-card:hover {
|
||||
border-color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.build-variant-card.selected {
|
||||
border-color: #4a9eff;
|
||||
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
|
||||
}
|
||||
|
||||
.variant-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.variant-keystone {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.variant-item {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 184, 225, 0.3);
|
||||
}
|
||||
|
||||
.variant-item-placeholder {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 184, 225, 0.3);
|
||||
}
|
||||
|
||||
.variant-pickrate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-on-surface);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Main Build Content */
|
||||
.build-content {
|
||||
display: flex;
|
||||
@@ -449,30 +345,6 @@ function selectBuild(index: number) {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Rune page selector dots */
|
||||
.rune-page-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rune-page-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rune-page-dot:hover {
|
||||
background: var(--color-on-surface);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rune-page-dot.active {
|
||||
background: #4a9eff;
|
||||
}
|
||||
|
||||
/* Left Column: Runes + Summoner Spells */
|
||||
.build-left-column {
|
||||
display: flex;
|
||||
@@ -486,46 +358,9 @@ function selectBuild(index: number) {
|
||||
}
|
||||
|
||||
.rune-page-wrapper {
|
||||
transform: scale(0.85);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/* Summoner Spells - Compact */
|
||||
.summoner-spells-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summoner-spells-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summoner-spell-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summoner-spell-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.summoner-spell-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.spell-pickrate {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-on-surface);
|
||||
opacity: 0.7;
|
||||
margin-top: -45px;
|
||||
transform: scale(0.7);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* Right Column: Items */
|
||||
@@ -557,48 +392,6 @@ function selectBuild(index: number) {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.item-row-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.item-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-on-surface);
|
||||
}
|
||||
|
||||
.item-pickrate {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-on-surface);
|
||||
opacity: 0.6;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.item-separator {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
background: var(--color-on-surface);
|
||||
opacity: 0.3;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* Responsive: Mobile */
|
||||
@media only screen and (max-width: 900px) {
|
||||
.build-content {
|
||||
@@ -617,26 +410,6 @@ function selectBuild(index: number) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.build-variant-card {
|
||||
min-width: 70px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.variant-keystone,
|
||||
.variant-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.variant-item-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.variant-pickrate {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -650,43 +423,5 @@ function selectBuild(index: number) {
|
||||
transform: scale(1);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.item-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.summoner-spell-img,
|
||||
.summoner-spell-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 400px) {
|
||||
.build-variant-selector {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.build-variant-card {
|
||||
min-width: 60px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.variant-keystone,
|
||||
.variant-item {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.variant-item-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,10 +12,13 @@ const emit = defineEmits<{
|
||||
parentReady: []
|
||||
}>()
|
||||
|
||||
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>('/api/cdragon/items', {
|
||||
lazy: true, // Don't block rendering
|
||||
server: false // Client-side only
|
||||
})
|
||||
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>(
|
||||
'/api/cdragon/items',
|
||||
{
|
||||
lazy: true, // Don't block rendering
|
||||
server: false // Client-side only
|
||||
}
|
||||
)
|
||||
|
||||
// Track image loading state
|
||||
const imagesLoaded = ref(false)
|
||||
@@ -47,31 +50,39 @@ const pendingChildMounts: Array<Element> = []
|
||||
|
||||
// Function to wait for an image to load
|
||||
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(resolve => {
|
||||
if (imgElement.complete) {
|
||||
requestAnimationFrame(() => resolve())
|
||||
return
|
||||
}
|
||||
|
||||
imgElement.addEventListener('load', () => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
}, { once: true })
|
||||
imgElement.addEventListener('error', () => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
}, { once: true })
|
||||
|
||||
imgElement.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
imgElement.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Wait for next tick to ensure DOM is ready
|
||||
await nextTick()
|
||||
|
||||
|
||||
// Wait for items to be loaded
|
||||
await new Promise<void>((resolve) => {
|
||||
await new Promise<void>(resolve => {
|
||||
if (!itemsLoading.value) {
|
||||
resolve()
|
||||
} else {
|
||||
const unwatch = watch(itemsLoading, (loading) => {
|
||||
const unwatch = watch(itemsLoading, loading => {
|
||||
if (!loading) {
|
||||
unwatch()
|
||||
resolve()
|
||||
@@ -79,20 +90,20 @@ onMounted(async () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (start.value) {
|
||||
const imgElement = start.value as HTMLImageElement
|
||||
imageElement.value = imgElement
|
||||
|
||||
|
||||
// Wait for own image to load
|
||||
await waitForImageLoad(imgElement)
|
||||
|
||||
|
||||
// Now that image is loaded and DOM is ready, draw arrows
|
||||
imagesLoaded.value = true
|
||||
|
||||
|
||||
// Notify children that parent is ready
|
||||
emit('parentReady')
|
||||
|
||||
|
||||
// Draw any pending arrows from children that mounted before we were ready
|
||||
if (pendingChildMounts.length > 0) {
|
||||
await nextTick()
|
||||
@@ -101,7 +112,7 @@ onMounted(async () => {
|
||||
}
|
||||
pendingChildMounts.length = 0
|
||||
}
|
||||
|
||||
|
||||
// Use multiple requestAnimationFrame to ensure rendering is complete
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -121,7 +132,7 @@ onBeforeUpdate(() => {
|
||||
|
||||
onUpdated(async () => {
|
||||
await nextTick()
|
||||
|
||||
|
||||
if (start.value && imagesLoaded.value) {
|
||||
// Redraw arrows after DOM update
|
||||
requestAnimationFrame(() => {
|
||||
@@ -291,4 +302,4 @@ function handleRefresh() {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user