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">
|
<script setup lang="ts">
|
||||||
import { isEmpty, deepClone } from '~/utils/helpers'
|
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<{
|
const props = defineProps<{
|
||||||
runes: Array<{
|
runes: Array<{
|
||||||
@@ -136,6 +140,35 @@ const firstCoreItems = computed(() => {
|
|||||||
return result
|
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
|
// Rune styles
|
||||||
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
|
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
|
||||||
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
|
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
|
||||||
@@ -179,64 +212,25 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
refreshStylesKeystones()
|
refreshStylesKeystones()
|
||||||
|
|
||||||
function selectBuild(index: number) {
|
|
||||||
currentlySelectedBuild.value = index
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="build-viewer">
|
<div class="build-viewer">
|
||||||
<!-- Global Build Variant Selector -->
|
<!-- Global Build Variant Selector - Single variant with highest pickrate -->
|
||||||
<div class="build-variant-selector">
|
<BuildVariantSelector
|
||||||
<div
|
:keystone-id="keystoneIds[highestPickrateBuildIndex]"
|
||||||
v-for="(_, i) in runes"
|
:item-id="firstCoreItems[highestPickrateBuildIndex]"
|
||||||
:key="i"
|
:keystore="perks"
|
||||||
:class="['build-variant-card', { selected: i === currentlySelectedBuild }]"
|
:item-map="itemMap"
|
||||||
@click="selectBuild(i)"
|
:pickrate="1"
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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 -->
|
<!-- Summoner Spells -->
|
||||||
<div class="summoner-spells-section">
|
<SummonerSpells :spells="mockSummonerSpells" :summoner-spell-map="summonerSpellMap" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Rune Page -->
|
<!-- Rune Page -->
|
||||||
<div class="rune-section">
|
<div class="rune-section">
|
||||||
@@ -249,6 +243,17 @@ function selectBuild(index: number) {
|
|||||||
:selection-ids="runes[currentlySelectedBuild].selections"
|
:selection-ids="runes[currentlySelectedBuild].selections"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -259,60 +264,31 @@ function selectBuild(index: number) {
|
|||||||
<!-- Start/Support + Boots Container -->
|
<!-- Start/Support + Boots Container -->
|
||||||
<div class="item-row-group">
|
<div class="item-row-group">
|
||||||
<!-- Start Items -->
|
<!-- Start Items -->
|
||||||
<div v-if="!builds.suppItems" class="item-row">
|
<ItemRow
|
||||||
<span class="item-row-label">Start</span>
|
v-if="!builds.suppItems"
|
||||||
<div class="item-row-content">
|
label="Start"
|
||||||
<div v-for="item in builds.start" :key="item.data" class="item-cell">
|
:items="builds.start"
|
||||||
<NuxtImg
|
:item-map="itemMap"
|
||||||
v-if="itemMap.get(item.data)"
|
:total-count="builds.tree.count"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Support Items -->
|
<!-- Support Items -->
|
||||||
<div v-if="builds.suppItems" class="item-row">
|
<ItemRow
|
||||||
<span class="item-row-label">Support</span>
|
v-if="builds.suppItems"
|
||||||
<div class="item-row-content">
|
label="Support"
|
||||||
<div v-for="item in builds.suppItems" :key="item.data" class="item-cell">
|
:items="builds.suppItems"
|
||||||
<NuxtImg
|
:item-map="itemMap"
|
||||||
v-if="itemMap.get(item.data)"
|
:total-count="builds.tree.count"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Boots (regular or rush) -->
|
<!-- Boots (regular or rush) -->
|
||||||
<div class="item-row">
|
<ItemRow
|
||||||
<span class="item-row-label">{{
|
:label="builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
|
||||||
builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'
|
:items="builds.boots.slice(0, 2)"
|
||||||
}}</span>
|
:item-map="itemMap"
|
||||||
<div class="item-row-content">
|
:total-count="builds.tree.count"
|
||||||
<div v-for="item in builds.boots.slice(0, 2)" :key="item.data" class="item-cell">
|
:max-items="2"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Core Items Tree -->
|
<!-- Core Items Tree -->
|
||||||
@@ -322,22 +298,13 @@ function selectBuild(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Late Game -->
|
<!-- Late Game -->
|
||||||
<div class="item-row">
|
<ItemRow
|
||||||
<span class="item-row-label">Late Game</span>
|
label="Late Game"
|
||||||
<div class="item-row-content">
|
:items="builds.lateGame.slice(0, 6)"
|
||||||
<div v-for="item in builds.lateGame.slice(0, 6)" :key="item.data" class="item-cell">
|
:item-map="itemMap"
|
||||||
<NuxtImg
|
:total-count="builds.tree.count"
|
||||||
v-if="itemMap.get(item.data)"
|
:max-items="6"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,77 +320,6 @@ function selectBuild(index: number) {
|
|||||||
align-items: center;
|
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 */
|
/* Main Build Content */
|
||||||
.build-content {
|
.build-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -449,30 +345,6 @@ function selectBuild(index: number) {
|
|||||||
letter-spacing: 0.5px;
|
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 */
|
/* Left Column: Runes + Summoner Spells */
|
||||||
.build-left-column {
|
.build-left-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -486,46 +358,9 @@ function selectBuild(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rune-page-wrapper {
|
.rune-page-wrapper {
|
||||||
transform: scale(0.85);
|
margin-top: -45px;
|
||||||
transform-origin: top left;
|
transform: scale(0.7);
|
||||||
}
|
transform-origin: center;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right Column: Items */
|
/* Right Column: Items */
|
||||||
@@ -557,48 +392,6 @@ function selectBuild(index: number) {
|
|||||||
letter-spacing: 0.5px;
|
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 */
|
/* Responsive: Mobile */
|
||||||
@media only screen and (max-width: 900px) {
|
@media only screen and (max-width: 900px) {
|
||||||
.build-content {
|
.build-content {
|
||||||
@@ -617,26 +410,6 @@ function selectBuild(index: number) {
|
|||||||
align-items: center;
|
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 {
|
.section-title {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -650,43 +423,5 @@ function selectBuild(index: number) {
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
transform-origin: center center;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ const emit = defineEmits<{
|
|||||||
parentReady: []
|
parentReady: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>('/api/cdragon/items', {
|
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>(
|
||||||
lazy: true, // Don't block rendering
|
'/api/cdragon/items',
|
||||||
server: false // Client-side only
|
{
|
||||||
})
|
lazy: true, // Don't block rendering
|
||||||
|
server: false // Client-side only
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Track image loading state
|
// Track image loading state
|
||||||
const imagesLoaded = ref(false)
|
const imagesLoaded = ref(false)
|
||||||
@@ -47,31 +50,39 @@ const pendingChildMounts: Array<Element> = []
|
|||||||
|
|
||||||
// Function to wait for an image to load
|
// Function to wait for an image to load
|
||||||
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
|
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
if (imgElement.complete) {
|
if (imgElement.complete) {
|
||||||
requestAnimationFrame(() => resolve())
|
requestAnimationFrame(() => resolve())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
imgElement.addEventListener('load', () => {
|
imgElement.addEventListener(
|
||||||
requestAnimationFrame(() => resolve())
|
'load',
|
||||||
}, { once: true })
|
() => {
|
||||||
imgElement.addEventListener('error', () => {
|
requestAnimationFrame(() => resolve())
|
||||||
requestAnimationFrame(() => resolve())
|
},
|
||||||
}, { once: true })
|
{ once: true }
|
||||||
|
)
|
||||||
|
imgElement.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Wait for next tick to ensure DOM is ready
|
// Wait for next tick to ensure DOM is ready
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Wait for items to be loaded
|
// Wait for items to be loaded
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>(resolve => {
|
||||||
if (!itemsLoading.value) {
|
if (!itemsLoading.value) {
|
||||||
resolve()
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
const unwatch = watch(itemsLoading, (loading) => {
|
const unwatch = watch(itemsLoading, loading => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
unwatch()
|
unwatch()
|
||||||
resolve()
|
resolve()
|
||||||
@@ -79,20 +90,20 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (start.value) {
|
if (start.value) {
|
||||||
const imgElement = start.value as HTMLImageElement
|
const imgElement = start.value as HTMLImageElement
|
||||||
imageElement.value = imgElement
|
imageElement.value = imgElement
|
||||||
|
|
||||||
// Wait for own image to load
|
// Wait for own image to load
|
||||||
await waitForImageLoad(imgElement)
|
await waitForImageLoad(imgElement)
|
||||||
|
|
||||||
// Now that image is loaded and DOM is ready, draw arrows
|
// Now that image is loaded and DOM is ready, draw arrows
|
||||||
imagesLoaded.value = true
|
imagesLoaded.value = true
|
||||||
|
|
||||||
// Notify children that parent is ready
|
// Notify children that parent is ready
|
||||||
emit('parentReady')
|
emit('parentReady')
|
||||||
|
|
||||||
// Draw any pending arrows from children that mounted before we were ready
|
// Draw any pending arrows from children that mounted before we were ready
|
||||||
if (pendingChildMounts.length > 0) {
|
if (pendingChildMounts.length > 0) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -101,7 +112,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
pendingChildMounts.length = 0
|
pendingChildMounts.length = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use multiple requestAnimationFrame to ensure rendering is complete
|
// Use multiple requestAnimationFrame to ensure rendering is complete
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -121,7 +132,7 @@ onBeforeUpdate(() => {
|
|||||||
|
|
||||||
onUpdated(async () => {
|
onUpdated(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (start.value && imagesLoaded.value) {
|
if (start.value && imagesLoaded.value) {
|
||||||
// Redraw arrows after DOM update
|
// Redraw arrows after DOM update
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -291,4 +302,4 @@ function handleRefresh() {
|
|||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user