Compare commits
2 Commits
20ccb20738
...
c362d6b12a
| Author | SHA1 | Date | |
|---|---|---|---|
|
c362d6b12a
|
|||
|
7833780bcb
|
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isEmpty, deepClone } from '~/utils/helpers'
|
import { 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'
|
||||||
@@ -14,204 +15,49 @@ const props = defineProps<{
|
|||||||
pickrate: number
|
pickrate: number
|
||||||
}>
|
}>
|
||||||
builds: Builds
|
builds: Builds
|
||||||
summonerSpells?: Array<{ id: number; count: number; pickrate: number }> // API data when available
|
summonerSpells?: Array<{ id: number; count: number; pickrate: number }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const currentlySelectedBuild = ref(0)
|
const currentlySelectedBuild = ref(0)
|
||||||
|
|
||||||
// Fetch items from cached API
|
// Use composables for data fetching
|
||||||
const { data: items } = useFetch<Array<Item>>('/api/cdragon/items', {
|
const { itemMap } = useItemMap()
|
||||||
lazy: true,
|
const { summonerSpellMap } = useSummonerSpellMap()
|
||||||
server: false
|
|
||||||
})
|
|
||||||
const itemMap = ref<Map<number, Item>>(new Map())
|
|
||||||
|
|
||||||
watch(
|
// Use composable for rune styles
|
||||||
items,
|
const { perks, primaryStyles, secondaryStyles, keystoneIds } = useRuneStyles(toRef(props, 'runes'))
|
||||||
newItems => {
|
|
||||||
if (Array.isArray(newItems)) {
|
// Use composable for builds management
|
||||||
const map = new Map<number, Item>()
|
const { builds } = useBuilds(toRef(props, 'builds'))
|
||||||
for (const item of newItems) {
|
|
||||||
if (item?.id) {
|
// Summoner spells data - use提供的 or fall back to mock
|
||||||
map.set(item.id, item)
|
const displaySummonerSpells = computed(() =>
|
||||||
}
|
(props.summonerSpells && props.summonerSpells.length > 0)
|
||||||
}
|
? props.summonerSpells
|
||||||
itemMap.value = map
|
: MOCK_SUMMONER_SPELLS
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch summoner spells from cached API
|
// Computed properties using utility functions
|
||||||
const { data: summonerSpellsData } = useFetch<Array<SummonerSpell>>(
|
const firstCoreItems = computed(() => getFirstCoreItems(props.runes, builds.value))
|
||||||
'/api/cdragon/summoner-spells',
|
|
||||||
{
|
|
||||||
lazy: true,
|
|
||||||
server: false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const summonerSpellMap = ref<Map<number, SummonerSpell>>(new Map())
|
|
||||||
|
|
||||||
watch(
|
const highestPickrateBuildIndex = computed(() => getHighestPickrateBuildIndex(props.runes))
|
||||||
summonerSpellsData,
|
|
||||||
newData => {
|
const bootsLabel = computed(() =>
|
||||||
if (Array.isArray(newData)) {
|
builds.value.bootsFirst > BOOTS_RUSH_THRESHOLD ? 'Boots Rush' : 'Boots'
|
||||||
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
|
// Reset selected build when runes change
|
||||||
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(
|
watch(
|
||||||
() => props.runes,
|
() => props.runes,
|
||||||
() => {
|
() => {
|
||||||
currentlySelectedBuild.value = 0
|
currentlySelectedBuild.value = 0
|
||||||
primaryStyles.value = Array(props.runes.length)
|
|
||||||
secondaryStyles.value = Array(props.runes.length)
|
|
||||||
keystoneIds.value = Array(props.runes.length)
|
|
||||||
refreshStylesKeystones()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
refreshStylesKeystones()
|
function selectRune(index: number): void {
|
||||||
|
currentlySelectedBuild.value = index
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -230,7 +76,7 @@ refreshStylesKeystones()
|
|||||||
<!-- 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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
32
frontend/composables/useBuilds.ts
Normal file
32
frontend/composables/useBuilds.ts
Normal 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 }
|
||||||
|
}
|
||||||
30
frontend/composables/useItemMap.ts
Normal file
30
frontend/composables/useItemMap.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Composable for fetching and managing item data from CDragon API
|
||||||
|
* Returns a reactive Map of item ID to item data
|
||||||
|
*/
|
||||||
|
export const useItemMap = () => {
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { itemMap }
|
||||||
|
}
|
||||||
93
frontend/composables/useRuneStyles.ts
Normal file
93
frontend/composables/useRuneStyles.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Composable for fetching and managing rune styles and keystones
|
||||||
|
* Transforms rune data into format needed for display components
|
||||||
|
*/
|
||||||
|
export const useRuneStyles = (runes: Ref<Array<{
|
||||||
|
count: number
|
||||||
|
primaryStyle: number
|
||||||
|
secondaryStyle: number
|
||||||
|
selections: Array<number>
|
||||||
|
pickrate: number
|
||||||
|
}>>) => {
|
||||||
|
const primaryStyles = ref<Array<PerkStyle>>(Array(runes.value.length))
|
||||||
|
const secondaryStyles = ref<Array<PerkStyle>>(Array(runes.value.length))
|
||||||
|
const keystoneIds = ref<Array<number>>(Array(runes.value.length))
|
||||||
|
|
||||||
|
const { data: perksData } = useFetch('/api/cdragon/perks')
|
||||||
|
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
|
||||||
|
|
||||||
|
const perks = reactive(new Map<number, Perk>())
|
||||||
|
watch(
|
||||||
|
perksData,
|
||||||
|
(newPerks) => {
|
||||||
|
if (Array.isArray(newPerks)) {
|
||||||
|
perks.clear()
|
||||||
|
for (const perk of newPerks) {
|
||||||
|
if (perk?.id) {
|
||||||
|
perks.set(perk.id, perk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function refreshStylesKeystones(): void {
|
||||||
|
if (!stylesData.value?.styles) return
|
||||||
|
|
||||||
|
primaryStyles.value = Array(runes.value.length)
|
||||||
|
secondaryStyles.value = Array(runes.value.length)
|
||||||
|
keystoneIds.value = Array(runes.value.length)
|
||||||
|
|
||||||
|
for (const style of stylesData.value.styles) {
|
||||||
|
for (const rune of runes.value) {
|
||||||
|
const runeIndex = runes.value.indexOf(rune)
|
||||||
|
|
||||||
|
if (style.id === rune.primaryStyle) {
|
||||||
|
primaryStyles.value[runeIndex] = style
|
||||||
|
|
||||||
|
// Find keystone from first slot
|
||||||
|
if (style.slots?.[0]?.perks) {
|
||||||
|
for (const perk of style.slots[0].perks) {
|
||||||
|
if (rune.selections.includes(perk)) {
|
||||||
|
keystoneIds.value[runeIndex] = perk
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.id === rune.secondaryStyle) {
|
||||||
|
secondaryStyles.value[runeIndex] = style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh when styles data loads or runes change
|
||||||
|
watch(
|
||||||
|
[stylesData, runes],
|
||||||
|
() => {
|
||||||
|
refreshStylesKeystones()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset when runes array changes
|
||||||
|
watch(
|
||||||
|
() => runes.value.length,
|
||||||
|
() => {
|
||||||
|
primaryStyles.value = Array(runes.value.length)
|
||||||
|
secondaryStyles.value = Array(runes.value.length)
|
||||||
|
keystoneIds.value = Array(runes.value.length)
|
||||||
|
refreshStylesKeystones()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
perks,
|
||||||
|
primaryStyles,
|
||||||
|
secondaryStyles,
|
||||||
|
keystoneIds
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/composables/useSummonerSpellMap.ts
Normal file
33
frontend/composables/useSummonerSpellMap.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Composable for fetching and managing summoner spell data from CDragon API
|
||||||
|
* Returns a reactive Map of spell ID to spell data
|
||||||
|
*/
|
||||||
|
export const useSummonerSpellMap = () => {
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { summonerSpellMap }
|
||||||
|
}
|
||||||
69
frontend/utils/buildHelpers.ts
Normal file
69
frontend/utils/buildHelpers.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { isEmpty } from './helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims the build tree to only show the first path
|
||||||
|
* Removes alternate build paths to keep the UI clean
|
||||||
|
*/
|
||||||
|
export 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)
|
||||||
|
|
||||||
|
// Also trim grandchildren to first path only
|
||||||
|
if (builds.tree.children[0]?.children) {
|
||||||
|
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes late game items that appear in the core build tree
|
||||||
|
* Prevents duplicate items from being shown
|
||||||
|
*/
|
||||||
|
export function trimLateGameItems(builds: Builds): void {
|
||||||
|
if (!builds?.tree || isEmpty(builds.lateGame)) return
|
||||||
|
|
||||||
|
const coreItemIds = new Set<number>()
|
||||||
|
|
||||||
|
// Collect all item IDs from the tree
|
||||||
|
function collectItemIds(tree: ItemTree): void {
|
||||||
|
if (tree.data !== undefined) {
|
||||||
|
coreItemIds.add(tree.data)
|
||||||
|
}
|
||||||
|
for (const child of tree.children || []) {
|
||||||
|
collectItemIds(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectItemIds(builds.tree)
|
||||||
|
|
||||||
|
// Remove late game items that appear in core
|
||||||
|
builds.lateGame = builds.lateGame.filter((item) => !coreItemIds.has(item.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the index of the build with the highest pickrate
|
||||||
|
*/
|
||||||
|
export function getHighestPickrateBuildIndex(
|
||||||
|
runes: Array<{ pickrate: number }>
|
||||||
|
): number {
|
||||||
|
if (runes.length === 0) return 0
|
||||||
|
|
||||||
|
return runes.reduce((maxIdx, rune, idx, arr) =>
|
||||||
|
rune.pickrate > arr[maxIdx].pickrate ? idx : maxIdx,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the first core item for each build variant
|
||||||
|
*/
|
||||||
|
export function getFirstCoreItems(
|
||||||
|
runes: unknown[],
|
||||||
|
builds: Builds
|
||||||
|
): number[] {
|
||||||
|
return runes.map(() => {
|
||||||
|
const tree = builds?.tree
|
||||||
|
return tree?.children?.[0]?.data ?? tree?.data ?? 0
|
||||||
|
})
|
||||||
|
}
|
||||||
16
frontend/utils/mockData.ts
Normal file
16
frontend/utils/mockData.ts
Normal 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
|
||||||
Reference in New Issue
Block a user