Lint and format
Some checks failed
pipeline / lint-and-format (push) Failing after 56s
pipeline / build-and-push-images (push) Has been skipped

This commit is contained in:
2026-01-21 00:59:23 +01:00
parent 353baa6267
commit 3fc52205f6
53 changed files with 8505 additions and 2048 deletions

11
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false
}

View File

@@ -1,2 +1 @@
# BuildPath - Nuxt

View File

@@ -3,15 +3,15 @@ useSeoMeta({
title: 'BuildPath',
ogTitle: 'BuildPath',
description: 'BuildPath: a tool for League of Legends champions runes and build paths.',
ogDescription: 'BuildPath: a tool for League of Legends champions runes and build paths.',
ogDescription: 'BuildPath: a tool for League of Legends champions runes and build paths.'
})
</script>
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</NuxtLayout>
</div>
</template>

View File

@@ -1,57 +1,72 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
:root {
--color-surface: #312E2C;
--color-on-surface: #B7B8E1;
--color-surface-darker: #1f1d1c;
--color-surface: #312e2c;
--color-on-surface: #b7b8e1;
--color-surface-darker: #1f1d1c;
}
/* Font setting */
h1,h2,h3,h4,h5,h6,p,a,input[type=text] {
font-family: "Inter", sans-serif;
font-optical-sizing: auto;
font-weight: normal;
font-style: normal;
h1,
h2,
h3,
h4,
h5,
h6,
p,
a,
input[type='text'] {
font-family: 'Inter', sans-serif;
font-optical-sizing: auto;
font-weight: normal;
font-style: normal;
color: var(--color-on-surface);
color: var(--color-on-surface);
}
/* Default margins to none */
h1,h2,h3,h4,h5,h6,p,a {
margin: 0px;
h1,
h2,
h3,
h4,
h5,
h6,
p,
a {
margin: 0px;
}
body {
background-color: var(--color-surface);
padding: 0;
margin: 0;
background-color: var(--color-surface);
/* default value: font-size: 16px; */
padding: 0;
margin: 0;
/* default value: font-size: 16px; */
}
@media only screen and (max-width: 650px) {
body {
font-size: 12px;
}
html {
font-size: 12px;
}
body {
font-size: 12px;
}
html {
font-size: 12px;
}
}
/* Different title settings */
h1 {
font-size: 2.5rem;
font-weight: 500;
font-size: 2.5rem;
font-weight: 500;
}
h2 {
font-size: 1.5rem;
font-weight: 400;
font-size: 1.5rem;
font-weight: 400;
}
h3 {
font-size: 1.5rem;
font-weight: 300;
font-size: 1.5rem;
font-weight: 300;
}
a {
text-decoration: none;
text-decoration: none;
}

View File

@@ -1,305 +1,327 @@
<script setup lang="ts">
import { debounce, isEmpty } from '~/utils/helpers';
import { debounce, isEmpty } from '~/utils/helpers'
// Constants
const CDRAGON_CHAMPIONS_URL = CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json";
const CHAMPIONS_API_URL = "/api/champions";
const CDRAGON_CHAMPIONS_URL =
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
const CHAMPIONS_API_URL = '/api/champions'
// State
const { data: championsData, pending: loadingChampions, error: championsError } = useFetch(CDRAGON_CHAMPIONS_URL, {
const {
data: championsData,
pending: loadingChampions,
error: championsError
} = useFetch(CDRAGON_CHAMPIONS_URL, {
key: 'champions-data',
lazy: false,
server: false // Disable server-side fetching to avoid hydration issues
});
const { data: championsLanes, pending: loadingLanes, error: lanesError } = useFetch(CHAMPIONS_API_URL, {
})
const {
data: championsLanes,
pending: loadingLanes,
error: lanesError
} = useFetch(CHAMPIONS_API_URL, {
key: 'champions-lanes',
lazy: false,
server: false // Disable server-side fetching to avoid hydration issues
});
})
// Data processing
const champions = computed(() => {
if (!championsData.value || !Array.isArray(championsData.value)) return [];
if (!championsData.value || !Array.isArray(championsData.value)) return []
return championsData.value.slice(1)
.filter((champion: any) => !champion.name.includes("Doom Bot"))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
});
return championsData.value
.slice(1)
.filter((champion: any) => !champion.name.includes('Doom Bot'))
.sort((a: any, b: any) => a.name.localeCompare(b.name))
})
const lanesMap = computed(() => {
const map = new Map<string, LaneData[]>();
const map = new Map<string, LaneData[]>()
if (championsLanes.value) {
for (const champion of championsLanes.value as ChampionData[]) {
map.set(champion.alias.toLowerCase(), champion.lanes);
map.set(champion.alias.toLowerCase(), champion.lanes)
}
}
return map;
});
return map
})
// Filter state
const filteredChampions = ref<ChampionSummary[]>([]);
const searchText = ref("");
const searchBar = useTemplateRef("searchBar");
const filteredChampions = ref<ChampionSummary[]>([])
const searchText = ref('')
const searchBar = useTemplateRef('searchBar')
// Lane filtering
function filterToLane(filter: number): string {
const laneMap = ["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"];
return laneMap[filter] || "";
const laneMap = ['TOP', 'JUNGLE', 'MIDDLE', 'BOTTOM', 'UTILITY']
return laneMap[filter] || ''
}
function filterChampionsByLane(laneFilter: number): void {
if (laneFilter === -1) {
filteredChampions.value = [...champions.value];
return;
filteredChampions.value = [...champions.value]
return
}
const laneName = filterToLane(laneFilter);
const laneName = filterToLane(laneFilter)
filteredChampions.value = champions.value.filter((champion: any) => {
const championLanes = lanesMap.value.get(champion.alias.toLowerCase());
if (!championLanes) return false;
const championLanes = lanesMap.value.get(champion.alias.toLowerCase())
if (!championLanes) return false
return championLanes.some(lane => lane.data === laneName);
});
return championLanes.some(lane => lane.data === laneName)
})
}
// Search functionality
const debouncedSearch = debounce((searchTerm: string) => {
if (isEmpty(searchTerm)) {
filteredChampions.value = [...champions.value];
filteredChampions.value = [...champions.value]
} else {
filteredChampions.value = champions.value.filter((champion: any) =>
champion.name.toLowerCase().includes(searchTerm.toLowerCase())
);
)
}
}, 300);
}, 300)
// Watchers
watch(searchBar, (newS, oldS) => {
searchBar.value?.focus();
});
searchBar.value?.focus()
})
watch(searchText, (newTerm) => {
debouncedSearch(newTerm);
});
watch(searchText, newTerm => {
debouncedSearch(newTerm)
})
// Watch for changes in champions data and update filtered champions
watch(champions, (newChampions) => {
filteredChampions.value = [...newChampions];
}, { immediate: true });
watch(
champions,
newChampions => {
filteredChampions.value = [...newChampions]
},
{ immediate: true }
)
// Navigation
async function navigateToChampion(championAlias: string): Promise<void> {
try {
await navigateTo(`/champion/${championAlias.toLowerCase()}`);
await navigateTo(`/champion/${championAlias.toLowerCase()}`)
} catch (error) {
console.error('Navigation error:', error);
console.error('Navigation error:', error)
}
}
// Initialize filtered champions
onMounted(() => {
filteredChampions.value = [...champions.value];
});
filteredChampions.value = [...champions.value]
})
// Error handling
const hasErrors = computed(() => championsError.value || lanesError.value);
const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
const hasErrors = computed(() => championsError.value || lanesError.value)
const isLoading = computed(() => loadingChampions.value || loadingLanes.value)
</script>
<template>
<div>
<!-- Loading state -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Loading champions...</p>
</div>
<!-- Error state -->
<div v-else-if="hasErrors" class="error-state">
<p>Failed to load champion data. Please refresh the page.</p>
</div>
<!-- Main content -->
<div v-else>
<div class="search-lanefilter-container">
<LaneFilter
id="cs-lanefilter"
@filter-change="filterChampionsByLane"
/>
<input
@keyup.enter="() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)"
v-model="searchText"
ref="searchBar"
class="search-bar"
type="text"
placeholder="Search a champion"
/>
</div>
<!-- Empty state -->
<div v-if="filteredChampions.length === 0" class="empty-state">
<p>No champions found. Try a different search or filter.</p>
</div>
<div v-else class="champion-container">
<NuxtLink
v-for="champion in filteredChampions"
:key="champion.id"
:to="'/champion/' + champion.alias.toLowerCase()"
style="width: fit-content; height: fit-content;"
>
<div class="cs-champion-img-container">
<NuxtImg
format="webp"
class="cs-champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</div>
</div>
<div>
<!-- Loading state -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"/>
<p>Loading champions...</p>
</div>
<!-- Error state -->
<div v-else-if="hasErrors" class="error-state">
<p>Failed to load champion data. Please refresh the page.</p>
</div>
<!-- Main content -->
<div v-else>
<div class="search-lanefilter-container">
<LaneFilter id="cs-lanefilter" @filter-change="filterChampionsByLane" />
<input
ref="searchBar"
v-model="searchText"
class="search-bar"
type="text"
placeholder="Search a champion"
@keyup.enter="
() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)
"
>
</div>
<!-- Empty state -->
<div v-if="filteredChampions.length === 0" class="empty-state">
<p>No champions found. Try a different search or filter.</p>
</div>
<div v-else class="champion-container">
<NuxtLink
v-for="champion in filteredChampions"
:key="champion.id"
:to="'/champion/' + champion.alias.toLowerCase()"
style="width: fit-content; height: fit-content"
>
<div class="cs-champion-img-container">
<NuxtImg
format="webp"
class="cs-champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</div>
</div>
</div>
</template>
<style>
/* Loading and error states */
.loading-state, .error-state, .empty-state {
text-align: center;
padding: 40px 20px;
color: var(--color-on-surface);
font-size: 1.2rem;
.loading-state,
.error-state,
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--color-on-surface);
font-size: 1.2rem;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-surface);
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
width: 40px;
height: 40px;
border: 4px solid var(--color-surface);
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-state p {
margin: 0;
animation: pulse 1.5s ease-in-out infinite;
margin: 0;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.error-state {
color: var(--color-error);
color: var(--color-error);
}
.empty-state {
opacity: 0.7;
opacity: 0.7;
}
.search-bar {
width: 400px;
height: 60px;
width: 400px;
height: 60px;
background-color: var(--color-surface-darker);
font-size: 1.75rem;
background-color: var(--color-surface-darker);
border-radius: 12px;
border: none;
padding-left: 10px;
font-size: 1.75rem;
border-radius: 12px;
border: none;
padding-left: 10px;
}
.search-bar:focus {
border: 2px solid var(--color-on-surface);
outline: none;
border: 2px solid var(--color-on-surface);
outline: none;
}
#cs-lanefilter {
margin: auto;
margin-right: 20px;
margin: auto;
margin-right: 20px;
}
.search-lanefilter-container {
width: fit-content;
margin: auto;
display: flex;
align-items: center;
width: fit-content;
margin: auto;
display: flex;
align-items: center;
}
.champion-container {
width: 90%;
height: auto;
width: 90%;
height: auto;
display: grid;
grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px;
justify-content: center;
display: grid;
grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px;
justify-content: center;
margin: auto;
margin-top: 20px;
margin-bottom: 20px;
margin: auto;
margin-top: 20px;
margin-bottom: 20px;
}
.cs-champion-img-container {
overflow: hidden; width: 120px; height: 120px;
border: 1px solid var(--color-surface);
overflow: hidden;
width: 120px;
height: 120px;
border: 1px solid var(--color-surface);
}
.cs-champion-img-container:hover {
border: 1px solid var(--color-on-surface);
border: 1px solid var(--color-on-surface);
}
.cs-champion-img {
width: 116px;
height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2);
width: 116px;
height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none;
user-select: none;
}
@media only screen and (max-width: 920px) {
.search-lanefilter-container {
flex-direction: column;
}
#cs-lanefilter {
margin-right: auto;
}
.champion-container {
width: 100%;
}
.search-lanefilter-container {
flex-direction: column;
}
#cs-lanefilter {
margin-right: auto;
}
.champion-container {
width: 100%;
}
}
@media only screen and (max-width: 450px) {
.search-bar {
width: 92%;
height: 40px;
}
.cs-champion-img-container {
width: 80px;
height: 80px;
}
.cs-champion-img {
width: 76px;
height: 76px;
}
.champion-container {
grid-template-columns: repeat(auto-fit, 80px);
}
.search-lanefilter-container {
margin-top: 10px;
}
.search-bar {
width: 92%;
height: 40px;
}
.cs-champion-img-container {
width: 80px;
height: 80px;
}
.cs-champion-img {
width: 76px;
height: 76px;
}
.champion-container {
grid-template-columns: repeat(auto-fit, 80px);
}
.search-lanefilter-container {
margin-top: 10px;
}
}
</style>

View File

@@ -1,98 +1,122 @@
<script setup lang="ts">
const props = defineProps<{
championId: number,
winrate: number,
pickrate: number,
championId: number
winrate: number
pickrate: number
gameCount: number
}>()
const winrate = ref((props.winrate * 100).toFixed(2))
watch(() => props.winrate, () => {winrate.value = (props.winrate * 100).toFixed(2)})
watch(
() => props.winrate,
() => {
winrate.value = (props.winrate * 100).toFixed(2)
}
)
const pickrate = ref((props.pickrate * 100).toFixed(2))
watch(() => props.pickrate, () => {pickrate.value = (props.pickrate * 100).toFixed(2)})
watch(
() => props.pickrate,
() => {
pickrate.value = (props.pickrate * 100).toFixed(2)
}
)
const { data: championData } : ChampionResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champions/" + props.championId + ".json")
const { data: championData }: ChampionResponse = await useFetch(
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champions/' +
props.championId +
'.json'
)
const championName = championData.value.name
const championDescription = championData.value.title
</script>
<template>
<div style="display: flex; width: fit-content;">
<div class="champion-title-img-container">
<NuxtImg width="160" height="160" class="champion-title-img" :src="CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' + championId + '.png'"/>
</div>
<div id="ct-info-container">
<h1>{{ championName }}</h1>
<h3 id="ct-desc">{{ championDescription }}</h3>
<div id="ct-basic-stat-container">
<h2 class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 class="ct-basic-stat ct-basic-stat-margin">{{ pickrate }}% pick.</h2>
<h2 class="ct-basic-stat">{{ gameCount }} games</h2>
</div>
</div>
<div style="display: flex; width: fit-content">
<div class="champion-title-img-container">
<NuxtImg
width="160"
height="160"
class="champion-title-img"
:src="
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' +
championId +
'.png'
"
/>
</div>
<div id="ct-info-container">
<h1>{{ championName }}</h1>
<h3 id="ct-desc">{{ championDescription }}</h3>
<div id="ct-basic-stat-container">
<h2 class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 class="ct-basic-stat ct-basic-stat-margin">{{ pickrate }}% pick.</h2>
<h2 class="ct-basic-stat">{{ gameCount }} games</h2>
</div>
</div>
</div>
</template>
<style>
.champion-title-img-container {
width: 160px;
height: 160px;
overflow: hidden;
width: 160px;
height: 160px;
overflow: hidden;
border: 1px solid var(--color-on-surface);
border: 1px solid var(--color-on-surface);
}
.champion-title-img {
width: 160px;
height: 160px;
transform: translate(4px, 4px) scale(1.2, 1.2);
width: 160px;
height: 160px;
transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none;
user-select: none;
}
#ct-info-container {
margin-left: 15px;
margin-top: 5px;
margin-left: 15px;
margin-top: 5px;
}
.ct-basic-stat-margin {
margin-left: 20px;
margin-right: 20px;
margin-left: 20px;
margin-right: 20px;
}
#ct-desc {
margin-top: 5px;
margin-top: 5px;
}
#ct-basic-stat-container {
margin-top: 30px;
display: flex;
margin-top: 30px;
display: flex;
}
@media only screen and (max-width: 650px) {
.champion-title-img-container {
width: 86px;
height: 86px;
}
.champion-title-img {
width: 86px;
height: 86px;
}
#ct-desc {
display: none;
}
.ct-basic-stat {
text-align: center;
}
.ct-basic-stat-margin {
margin-left: 2px;
margin-right: 2px;
}
#ct-basic-stat-container {
margin-top: 10px;
}
#ct-info-container {
margin-left: 10px;
margin-top: 0px;
max-width: 220px;
}
.champion-title-img-container {
width: 86px;
height: 86px;
}
.champion-title-img {
width: 86px;
height: 86px;
}
#ct-desc {
display: none;
}
.ct-basic-stat {
text-align: center;
}
.ct-basic-stat-margin {
margin-left: 2px;
margin-right: 2px;
}
#ct-basic-stat-container {
margin-top: 10px;
}
#ct-info-container {
margin-left: 10px;
margin-top: 0px;
max-width: 220px;
}
}
</style>
</style>

View File

@@ -1,67 +1,75 @@
<script setup lang="ts">
import { LANE_IMAGES, LANE_IMAGES_HOVER, LANE_IMAGES_SELECTED, POSITIONS_STR } from '~/utils/cdragon';
import {
LANE_IMAGES,
LANE_IMAGES_HOVER,
LANE_IMAGES_SELECTED,
POSITIONS_STR
} from '~/utils/cdragon'
const emit = defineEmits<{
filterChange: [value: number]
filterChange: [value: number]
}>()
const laneImgs = Array(5).fill(ref("")).map((_, index) => ref(LANE_IMAGES[index]))
const laneImgs = Array(5)
.fill(ref(''))
.map((_, index) => ref(LANE_IMAGES[index]))
const laneFilter = ref(-1)
function selectLaneFilter(index: number) {
// Unselect previous filter
if(laneFilter.value != -1) {
laneImgs[laneFilter.value].value = LANE_IMAGES[laneFilter.value]
// Unselect previous filter
if (laneFilter.value != -1) {
laneImgs[laneFilter.value].value = LANE_IMAGES[laneFilter.value]
// This is a deselection.
if(laneFilter.value == index) {
laneFilter.value = -1;
emit('filterChange', laneFilter.value)
return;
}
// This is a deselection.
if (laneFilter.value == index) {
laneFilter.value = -1
emit('filterChange', laneFilter.value)
return
}
}
// Select new one
laneImgs[index].value = LANE_IMAGES_SELECTED[index]
laneFilter.value = index
emit('filterChange', laneFilter.value)
// Select new one
laneImgs[index].value = LANE_IMAGES_SELECTED[index]
laneFilter.value = index
emit('filterChange', laneFilter.value)
}
function handleMouseOut(laneImg: Ref<string>, index: number) {
if(laneImg.value == LANE_IMAGES_HOVER[index])
laneImg.value = LANE_IMAGES[index]
if (laneImg.value == LANE_IMAGES_HOVER[index]) laneImg.value = LANE_IMAGES[index]
}
function handleHover(laneImg: Ref<string>, index: number) {
if(laneImg.value == LANE_IMAGES[index])
laneImg.value = LANE_IMAGES_HOVER[index]
if (laneImg.value == LANE_IMAGES[index]) laneImg.value = LANE_IMAGES_HOVER[index]
}
</script>
<template>
<div style="width: fit-content;">
<NuxtImg v-for="(laneImg, index) in laneImgs"
format="webp"
:alt="POSITIONS_STR[index]"
class="lane-img" :src="laneImg.value"
@mouseout="handleMouseOut(laneImg, index)"
@mouseover="handleHover(laneImg, index)"
@click="selectLaneFilter(index)"/>
</div>
<div style="width: fit-content">
<NuxtImg
v-for="(laneImg, index) in laneImgs"
format="webp"
:alt="POSITIONS_STR[index]"
class="lane-img"
:src="laneImg.value"
@mouseout="handleMouseOut(laneImg, index)"
@mouseover="handleHover(laneImg, index)"
@click="selectLaneFilter(index)"
/>
</div>
</template>
<style>
.lane-img {
width: 64px;
margin-left: 5px;
margin-right: 5px;
width: 64px;
margin-left: 5px;
margin-right: 5px;
}
.lane-img:hover {
cursor: pointer;
cursor: pointer;
}
@media only screen and (max-width: 450px) {
.lane-img {
width: 48px;
}
.lane-img {
width: 48px;
}
}
</style>

View File

@@ -1,44 +1,49 @@
<script setup lang="ts">
defineProps<{
imgWidth?: String,
fontSize?: String
imgWidth?: string
fontSize?: string
}>()
</script>
<template>
<div style="width: fit-content; max-width: 100%; overflow: hidden;">
<NuxtLink style="display: flex; width: fit-content; text-decoration: none;" to="/">
<NuxtImg id="logo-img" alt="BuildPath"
format="webp"
:width="imgWidth == null ? '120' : Number(imgWidth)"
src="/buildpath-high-resolution-logo-transparent.png" />
<h1 :style="'font-size: ' + (fontSize == null ? '5.0rem' : fontSize) + ';'" id="logo-text">BuildPath</h1>
<div style="width: fit-content; max-width: 100%; overflow: hidden">
<NuxtLink style="display: flex; width: fit-content; text-decoration: none" to="/">
<NuxtImg
id="logo-img"
alt="BuildPath"
format="webp"
:width="imgWidth == null ? '120' : Number(imgWidth)"
src="/buildpath-high-resolution-logo-transparent.png"
/>
<h1 id="logo-text" :style="'font-size: ' + (fontSize == null ? '5.0rem' : fontSize) + ';'">
BuildPath
</h1>
</NuxtLink>
</div>
</div>
</template>
<style>
#logo-text {
font-weight: 200;
align-content: center;
font-weight: 200;
align-content: center;
margin-left: 20px;
user-select: none;
text-decoration: none;
margin-left: 20px;
user-select: none;
text-decoration: none;
}
#logo-img {
user-select: none;
user-select: none;
}
@media only screen and (max-width: 550px) {
#logo-text {
margin-left: 10px;
}
#logo-img {
max-width: 80px;
max-height: 103px;
height: fit-content;
}
#logo-text {
margin-left: 10px;
}
#logo-img {
max-width: 80px;
max-height: 103px;
height: fit-content;
}
}
</style>
</style>

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
defineProps<{
title: string,
id: number,
winrate: number,
pickrate: number,
title: string
id: number
winrate: number
pickrate: number
gameCount: number
}>()
</script>
<template>
@@ -23,13 +22,23 @@ defineProps<{
</div>
<div class="text-[200px]">
<!-- Champion image -->
<div class="my-auto ml-10"
style="overflow: hidden; width: 220px; height: 220px; border: 1px solid #B7B8E1;">
<NuxtImg width="216px" height="216px"
class="object-cover"
style="transform: translate(4px, 4px) scale(1.2, 1.2); width: 216px; height: 216px;"
:src="CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' + id + '.png'" />
</div>
<div
class="my-auto ml-10"
style="overflow: hidden; width: 220px; height: 220px; border: 1px solid #b7b8e1"
>
<NuxtImg
width="216px"
height="216px"
class="object-cover"
style="transform: translate(4px, 4px) scale(1.2, 1.2); width: 216px; height: 216px"
:src="
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' +
id +
'.png'
"
/>
</div>
</div>
</div>
<div class="flex flex-row items-center justify-between">
@@ -38,27 +47,21 @@ defineProps<{
<!-- <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#888888" d="m4.67 28l6.39-12l7.3 6.49a2 2 0 0 0 1.7.47a2 2 0 0 0 1.42-1.07L27 10.9l-1.82-.9l-5.49 11l-7.3-6.49a2 2 0 0 0-1.68-.51a2 2 0 0 0-1.42 1L4 25V2H2v26a2 2 0 0 0 2 2h26v-2Z" /></svg> -->
<div class="pl-2">
<div class="text-[#B7B8E1]">{{ (winrate * 100).toFixed(2) }}%</div>
<div class="text-lg text-[#B7B8E1]">
Winrate
</div>
<div class="text-lg text-[#B7B8E1]">Winrate</div>
</div>
</div>
<div class="flex flex-row pr-10">
<!-- <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#888888" d="m16 6.52l2.76 5.58l.46 1l1 .15l6.16.89l-4.38 4.3l-.75.73l.18 1l1.05 6.13l-5.51-2.89L16 23l-.93.49l-5.51 2.85l1-6.13l.18-1l-.74-.77l-4.42-4.35l6.16-.89l1-.15l.46-1L16 6.52M16 2l-4.55 9.22l-10.17 1.47l7.36 7.18L6.9 30l9.1-4.78L25.1 30l-1.74-10.13l7.36-7.17l-10.17-1.48Z" /></svg> -->
<div class="pl-2">
<div class="text-[#B7B8E1]">{{ (pickrate * 100).toFixed(2) }}%</div>
<div class="text-lg text-[#B7B8E1]">
Pickrate
</div>
<div class="text-lg text-[#B7B8E1]">Pickrate</div>
</div>
</div>
<div class="flex flex-row pr-10">
<!-- <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#888888" d="M22.45 6a5.47 5.47 0 0 1 3.91 1.64a5.7 5.7 0 0 1 0 8L16 26.13L5.64 15.64a5.7 5.7 0 0 1 0-8a5.48 5.48 0 0 1 7.82 0l2.54 2.6l2.53-2.58A5.44 5.44 0 0 1 22.45 6m0-2a7.47 7.47 0 0 0-5.34 2.24L16 7.36l-1.11-1.12a7.49 7.49 0 0 0-10.68 0a7.72 7.72 0 0 0 0 10.82L16 29l11.79-11.94a7.72 7.72 0 0 0 0-10.82A7.49 7.49 0 0 0 22.45 4Z" /></svg> -->
<div class="pl-2">
<div class="text-[#B7B8E1]">{{ gameCount }}</div>
<div class="text-lg text-[#B7B8E1]">
Games
</div>
<div class="text-lg text-[#B7B8E1]">Games</div>
</div>
</div>
</div>
@@ -86,4 +89,3 @@ defineProps<{
<div class="absolute bottom-0 w-full h-8 bg-[#B7B8E1]" />
</div>
</template>

View File

@@ -1,41 +1,49 @@
<script lang="ts" setup>
defineProps<{
title: string,
bootsFirst?: number,
sizePerc?: number
title: string
bootsFirst?: number
sizePerc?: number
}>()
</script>
<template>
<div :style="(sizePerc != undefined && sizePerc != null) ? 'max-height: ' + (sizePerc * 600) + 'px;' : ''" class="item-box">
<div style="display:flex; flex-direction: column; justify-content: center; align-items: center;">
<h2 class="item-box-title">{{ title }}</h2>
<h5 v-if="bootsFirst != undefined && bootsFirst != null"
style="margin: auto;">({{ (bootsFirst * 100).toFixed(2) }}%)</h5>
<div
:style="
sizePerc != undefined && sizePerc != null ? 'max-height: ' + sizePerc * 600 + 'px;' : ''
"
class="item-box"
>
<div
style="display: flex; flex-direction: column; justify-content: center; align-items: center"
>
<h2 class="item-box-title">{{ title }}</h2>
<h5 v-if="bootsFirst != undefined && bootsFirst != null" style="margin: auto">
({{ (bootsFirst * 100).toFixed(2) }}%)
</h5>
</div>
<slot/>
</div>
<slot />
</div>
</template>
<style scoped>
.item-box {
border: 1px solid var(--color-on-surface);
border-radius: 8px;
border: 1px solid var(--color-on-surface);
border-radius: 8px;
margin: 10px;
margin: 10px;
width: fit-content;
height: 600px;
width: fit-content;
height: 600px;
}
.item-box-title {
font-variant: small-caps;
text-align: center;
margin: 10px;
font-variant: small-caps;
text-align: center;
margin: 10px;
}
@media only screen and (max-width: 1000px) {
.item-box {
width: 95%;
height: fit-content;
}
.item-box {
width: 95%;
height: fit-content;
}
}
</style>
</style>

View File

@@ -1,112 +1,128 @@
<script setup lang="ts">
import svgdomarrows from 'svg-dom-arrows'
defineProps<{
tree: ItemTree,
parentCount?: number
tree: ItemTree
parentCount?: number
}>()
const emit = defineEmits<{
mount: [end: Element],
refresh: []
mount: [end: Element]
refresh: []
}>()
const {data : items} : ItemResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/items.json")
const { data: items }: ItemResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
)
const itemMap = reactive(new Map())
for(let item of items.value) {
itemMap.set(item.id, item)
for (const item of items.value) {
itemMap.set(item.id, item)
}
import svgdomarrows from 'svg-dom-arrows';
const start : Ref<Element | null> = useTemplateRef("start")
const arrows : Array<svgdomarrows.LinePath> = []
const start: Ref<Element | null> = useTemplateRef('start')
const arrows: Array<svgdomarrows.LinePath> = []
onMounted(() => {
refreshArrows()
emit('mount', start.value!!)
refreshArrows()
emit('mount', start.value!)
})
onBeforeUpdate(() => {
for(let arrow of arrows) {
arrow.release()
}
arrows.splice(0, arrows.length)
for (const arrow of arrows) {
arrow.release()
}
arrows.splice(0, arrows.length)
})
onUpdated(() => {
refreshArrows()
emit('mount', start.value!!)
refreshArrows()
emit('mount', start.value!)
})
onUnmounted(() => {
for(let arrow of arrows) {
arrow.release()
}
for (const arrow of arrows) {
arrow.release()
}
})
function drawArrow(start : Element, end : Element) {
// console.log("drawArrow(", start, ", ", end, ")")
if(start == null || end == null) return;
function drawArrow(start: Element, end: Element) {
// console.log("drawArrow(", start, ", ", end, ")")
if (start == null || end == null) return
const arrow = new svgdomarrows.LinePath({
start: {
element: start,
position: {
top: 0.5,
left: 1
}
},
end: {
element: end,
position: {
top: 0.5,
left: 0
}
},
style: 'stroke:var(--color-on-surface);stroke-width:3;fill:transparent;',
appendTo: document.body
})
arrows.push(arrow)
const arrow = new svgdomarrows.LinePath({
start: {
element: start,
position: {
top: 0.5,
left: 1
}
},
end: {
element: end,
position: {
top: 0.5,
left: 0
}
},
style: 'stroke:var(--color-on-surface);stroke-width:3;fill:transparent;',
appendTo: document.body
})
arrows.push(arrow)
}
function refreshArrows() {
for(let arrow of arrows) {
arrow.redraw()
}
for (const arrow of arrows) {
arrow.redraw()
}
}
// Redraw arrows on window resize
addEventListener('resize', (_) => {
refreshArrows()
addEventListener('resize', _ => {
refreshArrows()
})
addEventListener("scroll", (_) => {
refreshArrows()
addEventListener('scroll', _ => {
refreshArrows()
})
function handleSubtreeMount(end : Element) {
drawArrow(start.value!!, end)
refreshArrows()
emit('refresh')
function handleSubtreeMount(end: Element) {
drawArrow(start.value!, end)
refreshArrows()
emit('refresh')
}
function handleRefresh() {
refreshArrows()
emit('refresh')
refreshArrows()
emit('refresh')
}
</script>
<template>
<div style="display: flex; align-items: center;">
<div v-if="tree.data != undefined && tree.data != null" style="width: fit-content; height: fit-content;">
<img ref="start" class="item-img" width="64" height="64"
:alt="tree.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(tree.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%</h3>
</div>
<div style="margin-left: 30px;">
<div style="width: fit-content; height: fit-content;" v-for="child in tree.children">
<ItemTree @refresh="handleRefresh" @mount="(end) => handleSubtreeMount(end)" :tree="child" :parent-count="tree.count" />
</div>
</div>
<div style="display: flex; align-items: center">
<div
v-if="tree.data != undefined && tree.data != null"
style="width: fit-content; height: fit-content"
>
<img
ref="start"
class="item-img"
width="64"
height="64"
:alt="tree.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(tree.data).iconPath)"
>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%
</h3>
</div>
<div style="margin-left: 30px">
<div v-for="child in tree.children" style="width: fit-content; height: fit-content">
<ItemTree
:tree="child"
:parent-count="tree.count"
@refresh="handleRefresh"
@mount="end => handleSubtreeMount(end)"
/>
</div>
</div>
</div>
</template>

View File

@@ -1,64 +1,72 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers';
import { isEmpty, deepClone } from '~/utils/helpers'
const props = defineProps<{
builds: Builds;
loading?: boolean;
error?: boolean;
}>();
builds: Builds
loading?: boolean
error?: boolean
}>()
// Constants
const ITEMS_API_URL = CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/items.json";
const ITEMS_API_URL = CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
// State
const { data: items, pending: loadingItems, error: itemsError } = await useFetch(ITEMS_API_URL);
const itemMap = ref<Map<number, any>>(new Map());
const { data: items, pending: loadingItems, error: itemsError } = await useFetch(ITEMS_API_URL)
const itemMap = ref<Map<number, any>>(new Map())
// Initialize item map
watch(items, (newItems) => {
try {
const itemsData = newItems || [];
if (Array.isArray(itemsData)) {
const map = new Map<number, any>();
for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item);
watch(
items,
newItems => {
try {
const itemsData = newItems || []
if (Array.isArray(itemsData)) {
const map = new Map<number, any>()
for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
itemMap.value = map;
} catch (error) {
console.error('Error initializing item map:', error)
}
} catch (error) {
console.error('Error initializing item map:', error);
}
}, { immediate: true });
},
{ immediate: true }
)
// Builds management
const builds = ref<Builds>(deepClone(props.builds));
const builds = ref<Builds>(deepClone(props.builds))
watch(() => props.builds, (newBuilds) => {
builds.value = deepClone(newBuilds);
trimBuilds(builds.value);
trimLateGameItems(builds.value);
}, { deep: true });
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);
});
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;
if (!builds?.tree?.children) return
// Keep only the first child (primary build path)
builds.tree.children.splice(1, builds.tree.children.length - 1);
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);
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
}
@@ -66,161 +74,206 @@ function trimBuilds(builds: Builds): void {
* Remove items from lateGame that are already in the build tree
*/
function trimLateGameItems(builds: Builds): void {
if (!builds?.tree || isEmpty(builds.lateGame)) return;
if (!builds?.tree || isEmpty(builds.lateGame)) return
function trimLateGameItemsFromTree(tree: ItemTree): void {
const foundIndex = builds.lateGame.findIndex((x) => x.data === tree.data);
const foundIndex = builds.lateGame.findIndex(x => x.data === tree.data)
if (foundIndex !== -1) {
builds.lateGame.splice(foundIndex, 1);
builds.lateGame.splice(foundIndex, 1)
}
for (const child of tree.children || []) {
trimLateGameItemsFromTree(child);
trimLateGameItemsFromTree(child)
}
}
trimLateGameItemsFromTree(builds.tree);
trimLateGameItemsFromTree(builds.tree)
}
/**
* Get item data safely
*/
function getItemData(itemId: number): any {
return itemMap.value.get(itemId) || { iconPath: '' };
return itemMap.value.get(itemId) || { iconPath: '' }
}
/**
* Calculate percentage for item display
*/
function getItemPercentage(item: { count: number }, total: number): string {
if (total <= 0) return '0%';
return ((item.count / total) * 100).toFixed(0) + '%';
if (total <= 0) return '0%'
return ((item.count / total) * 100).toFixed(0) + '%'
}
// Error and loading states
const hasError = computed(() => itemsError.value || props.error);
const isLoading = computed(() => loadingItems.value || props.loading);
const hasError = computed(() => itemsError.value || props.error)
const isLoading = computed(() => loadingItems.value || props.loading)
</script>
<template>
<div id="iv-container">
<div>
<!-- Start items -->
<ItemBox title="start" v-if="builds.suppItems == undefined || builds.suppItems == null">
<div class="iv-items-container">
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.start" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<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 style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.suppItems" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3>
</div>
</div>
</ItemBox>
<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" style="margin-left: 5px; margin-right: 5px">
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<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" style="margin-left: 5px; margin-right: 5px">
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<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" style="margin-left: 5px; margin-right: 5px">
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<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)" style="margin-left: 5px; margin-right: 5px">
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<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)"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
<!-- Boots first : when champion rush boots -->
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :bootsFirst="builds.bootsFirst">
<div class="iv-items-container">
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.boots" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<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 style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.boots.slice(0, 4)" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<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 style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.lateGame.slice(0, 4)" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3>
</div>
</div>
<div class="iv-items-container" v-if="builds.lateGame.length > 4">
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.lateGame.slice(4, 8)" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3>
</div>
</div>
</div>
</ItemBox>
</div>
<div v-if="builds.lateGame.length > 4" class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(4, 8)"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<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;
display: flex;
width: fit-content;
height: fit-content;
}
.iv-items-container {
display: flex;
flex-direction: column;
width: fit-content;
height: fit-content;
margin:auto;
display: flex;
flex-direction: column;
width: fit-content;
height: fit-content;
margin: auto;
}
.item-img {
border: 1px solid var(--color-on-surface);
margin: 10px;
border: 1px solid var(--color-on-surface);
margin: 10px;
}
#iv-late-game-container {
display: flex;
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;
}
#iv-container {
flex-direction: column;
width: 100%;
}
.iv-items-container {
flex-direction: row;
}
.item-img {
width: 48px;
height: 48px;
}
#iv-late-game-container {
flex-direction: column;
}
}
</style>

View File

@@ -1,22 +1,25 @@
<script lang="ts" setup>
defineProps<{
championName?: string
championLanes?: Array<LaneData>
tierlistList?: boolean
championName?: string
championLanes?: Array<LaneData>
tierlistList?: boolean
}>()
const emit = defineEmits<{
stateChange: [state: string, lane: number]
stateChange: [state: string, lane: number]
}>()
</script>
<template>
<LazyNavSideBar :champion-name="championName"
:champion-lanes="championLanes"
:tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"/>
<LazyNavBottomBar :champion-name="championName"
:champion-lanes="championLanes"
:tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"/>
</template>
<LazyNavSideBar
:champion-name="championName"
:champion-lanes="championLanes"
:tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"
/>
<LazyNavBottomBar
:champion-name="championName"
:champion-lanes="championLanes"
:tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"
/>
</template>

View File

@@ -1,111 +1,145 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon';
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
defineProps<{
championName?: string
championLanes?: Array<LaneData>
tierlistList?: boolean
championName?: string
championLanes?: Array<LaneData>
tierlistList?: boolean
}>()
const emit = defineEmits<{
stateChange: [state: string, lane: number]
stateChange: [state: string, lane: number]
}>()
const state = ref("runes")
const state = ref('runes')
const laneState = ref(0)
function handleStateChange(newState : string, newLane: number) {
state.value = newState;
laneState.value = newLane;
emit('stateChange', newState, newLane)
function handleStateChange(newState: string, newLane: number) {
state.value = newState
laneState.value = newLane
emit('stateChange', newState, newLane)
}
const route = useRoute()
const selected = ref("");
if(route.path.startsWith("/tierlist/")) {
const lane = route.params.lane as string
selected.value = lane
const selected = ref('')
if (route.path.startsWith('/tierlist/')) {
const lane = route.params.lane as string
selected.value = lane
}
</script>
<template>
<div class="navbar-container">
<NuxtLink
style="display: flex; width: fit-content; text-decoration: none; align-items: center; margin-left: 10px;"
to="/"
prefetch
<div class="navbar-container">
<NuxtLink
style="
display: flex;
width: fit-content;
text-decoration: none;
align-items: center;
margin-left: 10px;
"
to="/"
prefetch
>
<NuxtImg
id="navbar-logo-img"
format="webp"
src="/buildpath-high-resolution-logo-transparent.png"
/>
</NuxtLink>
<div
v-for="(lane, i) in championLanes"
style="display: flex; align-items: center; margin-left: 20px"
>
<NuxtImg
format="webp"
width="40"
height="40"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
/>
<div>
<h2
:class="
'navbar-link ' + (state == 'runes' && laneState == i ? 'navbar-link-selected' : '')
"
@click="handleStateChange('runes', i)"
>
<NuxtImg format="webp" id="navbar-logo-img"
src="/buildpath-high-resolution-logo-transparent.png" />
</NuxtLink>
<div v-for="(lane, i) in championLanes" style="display: flex; align-items: center; margin-left: 20px;">
<NuxtImg format="webp" width="40" height="40"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]" />
<div>
<h2 :class="'navbar-link ' + (state == 'runes' && laneState == i ? 'navbar-link-selected' : '')"
@click="handleStateChange('runes', i)" >
Runes
</h2>
<h2 :class="'navbar-link ' + (state == 'items' && laneState == i ? 'navbar-link-selected' : '')"
@click="handleStateChange('items', i)" >
Items
</h2>
</div>
</div>
<div v-if="tierlistList == true" style="padding-left: 20px;">
<h2 style="padding-left: 0px; font-size: 1.4rem; margin-top: 15px;">Tierlist</h2>
<div style="display: flex;">
<NuxtLink style="margin-top: 5px; margin-bottom: 5px;" v-for="(pos, i) in POSITIONS" :to="'/tierlist/' + pos">
<div :class="selected == pos ? 'navbar-link-selected' : ''"
class="navbar-link" style="display: flex; align-items: center;">
<NuxtImg format="webp"
width="30" height="30"
:src="LANE_IMAGES[i]" :alt="POSITIONS_STR[i]" />
</div>
</NuxtLink>
</div>
</div>
Runes
</h2>
<h2
:class="
'navbar-link ' + (state == 'items' && laneState == i ? 'navbar-link-selected' : '')
"
@click="handleStateChange('items', i)"
>
Items
</h2>
</div>
</div>
<div v-if="tierlistList == true" style="padding-left: 20px">
<h2 style="padding-left: 0px; font-size: 1.4rem; margin-top: 15px">Tierlist</h2>
<div style="display: flex">
<NuxtLink
v-for="(pos, i) in POSITIONS"
style="margin-top: 5px; margin-bottom: 5px"
:to="'/tierlist/' + pos"
>
<div
:class="selected == pos ? 'navbar-link-selected' : ''"
class="navbar-link"
style="display: flex; align-items: center"
>
<NuxtImg
format="webp"
width="30"
height="30"
:src="LANE_IMAGES[i]"
:alt="POSITIONS_STR[i]"
/>
</div>
</NuxtLink>
</div>
</div>
</div>
</template>
<style>
.navbar-container {
display: flex;
display: flex;
position: fixed;
bottom: 0;
z-index: 10;
position: fixed;
bottom: 0;
z-index: 10;
background-color: #2B2826;
width: 100%;
height: 100px;
background-color: #2b2826;
width: 100%;
height: 100px;
margin: 0px;
margin: 0px;
}
.navbar-link {
user-select: none;
margin: 5px;
padding: 5px;
border-radius: 8px;
user-select: none;
margin: 5px;
padding: 5px;
border-radius: 8px;
}
.navbar-link:hover {
cursor: pointer;
background-color: var(--color-surface-darker);
cursor: pointer;
background-color: var(--color-surface-darker);
}
.navbar-link-selected {
background-color: var(--color-surface);
background-color: var(--color-surface);
}
#navbar-logo-img {
height: 70px;
width: fit-content;
max-width: 55px;
height: 70px;
width: fit-content;
max-width: 55px;
}
@media only screen and (min-width: 1200px) {
.navbar-container {
display: none;
}
.navbar-container {
display: none;
}
}
</style>

View File

@@ -1,135 +1,181 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon';
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
defineProps<{
championName?: string
championLanes?: Array<LaneData>
tierlistList?: boolean
championName?: string
championLanes?: Array<LaneData>
tierlistList?: boolean
}>()
const emit = defineEmits<{
stateChange: [state: string, lane: number]
stateChange: [state: string, lane: number]
}>()
const state = ref("runes")
const state = ref('runes')
const laneState = ref(0)
function handleStateChange(newState : string, newLane: number) {
state.value = newState;
laneState.value = newLane;
emit('stateChange', newState, newLane)
function handleStateChange(newState: string, newLane: number) {
state.value = newState
laneState.value = newLane
emit('stateChange', newState, newLane)
}
const {data: stats}: {data: Ref<{patch: number, count: number}>} = await useFetch("/api/stats")
const { data: stats }: { data: Ref<{ patch: number; count: number }> } =
await useFetch('/api/stats')
const route = useRoute()
const selected = ref("");
if(route.path.startsWith("/tierlist/")) {
const lane = route.params.lane as string
selected.value = lane
const selected = ref('')
if (route.path.startsWith('/tierlist/')) {
const lane = route.params.lane as string
selected.value = lane
}
</script>
<template>
<!-- To make content have a 300px margin -->
<div class="sidebar-margin"></div>
<!-- To make content have a 300px margin -->
<div class="sidebar-margin"/>
<div class="sidebar-container">
<Logo font-size="2.6rem" img-width="60" style="padding-left: 15px; padding-right: 15px; margin-top: 30px;"/>
<div class="sidebar-container">
<Logo
font-size="2.6rem"
img-width="60"
style="padding-left: 15px; padding-right: 15px; margin-top: 30px"
/>
<div v-for="(lane, i) in championLanes">
<div style="display: flex; align-items: center; margin-top: 30px; padding-right: 10px; overflow: hidden;">
<h1 style="font-size: 2.4rem; padding-left: 20px;">{{ championName }}</h1>
<NuxtImg format="webp" style="margin-left: 10px;"
width="40" height="40"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]" />
<h2 v-if="championName != null && championName != undefined && championName.length < 8"
style="margin-left: 5px; font-size: 1.8rem; font-weight: 200;">
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
<div v-for="(lane, i) in championLanes">
<div
style="
display: flex;
align-items: center;
margin-top: 30px;
padding-right: 10px;
overflow: hidden;
"
>
<h1 style="font-size: 2.4rem; padding-left: 20px">{{ championName }}</h1>
<NuxtImg
format="webp"
style="margin-left: 10px"
width="40"
height="40"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
/>
<h2
v-if="championName != null && championName != undefined && championName.length < 8"
style="margin-left: 5px; font-size: 1.8rem; font-weight: 200"
>
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
</h2>
</div>
</div>
<h2 :class="'sidebar-link ' + (state == 'runes' && laneState == i ? 'sidebar-link-selected' : '')"
@click="handleStateChange('runes', i)"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px;">Runes</h2>
<h2 :class="'sidebar-link ' + (state == 'items' && laneState == i ? 'sidebar-link-selected' : '')"
@click="handleStateChange('items', i)"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px;">Items</h2>
<h2
:class="
'sidebar-link ' + (state == 'runes' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
@click="handleStateChange('runes', i)"
>
Runes
</h2>
<h2
:class="
'sidebar-link ' + (state == 'items' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
@click="handleStateChange('items', i)"
>
Items
</h2>
<h2 :class="'sidebar-link ' + (state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')"
@click="handleStateChange('alternatives', i)"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px;">Alternatives</h2>
</div>
<div v-if="tierlistList == true" style="margin-top: 30px;">
<h2 style="padding-left: 20px; font-size: 2.4rem; margin-bottom: 10px;">Tierlist</h2>
<NuxtLink style="margin-top: 5px; margin-bottom: 5px;" v-for="(pos, i) in POSITIONS" :to="'/tierlist/' + pos">
<div :class="selected == pos ? 'sidebar-link-selected' : ''"
class="sidebar-link" style="padding-left: 35px; display: flex; align-items: center;">
<NuxtImg format="webp"
width="40" height="40"
:src="LANE_IMAGES[i]" :alt="POSITIONS_STR[i]" />
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px;">{{ POSITIONS_STR[i] }}</h3>
</div>
</NuxtLink>
</div>
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px;">
<h3 style="font-size: 23px; font-weight: 200;">
Patch {{ stats.patch }}
</h3>
<h3 style="font-size: 23px; font-weight: 200;">
{{ stats.count }} games
</h3>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px;">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of
Riot Games or anyone officially involved in producing or managing Riot Games properties.
Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc.
</h2>
</div>
<h2
:class="
'sidebar-link ' +
(state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
@click="handleStateChange('alternatives', i)"
>
Alternatives
</h2>
</div>
<div v-if="tierlistList == true" style="margin-top: 30px">
<h2 style="padding-left: 20px; font-size: 2.4rem; margin-bottom: 10px">Tierlist</h2>
<NuxtLink
v-for="(pos, i) in POSITIONS"
style="margin-top: 5px; margin-bottom: 5px"
:to="'/tierlist/' + pos"
>
<div
:class="selected == pos ? 'sidebar-link-selected' : ''"
class="sidebar-link"
style="padding-left: 35px; display: flex; align-items: center"
>
<NuxtImg
format="webp"
width="40"
height="40"
:src="LANE_IMAGES[i]"
:alt="POSITIONS_STR[i]"
/>
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px">
{{ POSITIONS_STR[i] }}
</h3>
</div>
</NuxtLink>
</div>
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px">
<h3 style="font-size: 23px; font-weight: 200">Patch {{ stats.patch }}</h3>
<h3 style="font-size: 23px; font-weight: 200">{{ stats.count }} games</h3>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot Games,
Inc.
</h2>
</div>
</div>
</template>
<style>
.sidebar-container {
background-color: #2B2826;
width: 300px;
background-color: #2b2826;
width: 300px;
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 10;
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 10;
}
.sidebar-margin {
width: 300px;
flex-shrink: 0;
width: 300px;
flex-shrink: 0;
}
.sidebar-link {
user-select: none;
margin: 5px;
padding-top: 5px;
padding-bottom: 5px;
border-radius: 8px;
user-select: none;
margin: 5px;
padding-top: 5px;
padding-bottom: 5px;
border-radius: 8px;
}
.sidebar-link:hover {
cursor: pointer;
cursor: pointer;
background-color: var(--color-surface-darker);
background-color: var(--color-surface-darker);
}
.sidebar-link-selected {
background-color: var(--color-surface);
background-color: var(--color-surface);
}
@media only screen and (max-width: 1200px) {
.sidebar-container {
display: none;
}
.sidebar-margin {
display: none;
}
.sidebar-container {
display: none;
}
.sidebar-margin {
display: none;
}
}
</style>
</style>

View File

@@ -1,116 +1,153 @@
<script setup lang="ts">
const props = defineProps<{
primaryStyleId: number
secondaryStyleId: number
selectionIds: Array<number>
primaryStyleId: number
secondaryStyleId: number
selectionIds: Array<number>
}>()
const primaryStyle : Ref<PerkStyle> = ref({id:0, name:"", iconPath:"", slots:[]})
const secondaryStyle : Ref<PerkStyle> = ref({id:0, name:"", iconPath:"", slots:[]})
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
let { data: perks_data } : PerksResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/perks.json")
const { data: perks_data }: PerksResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
)
const perks = reactive(new Map())
for(let perk of perks_data.value) {
perks.set(perk.id, perk)
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
let { data: stylesData } : PerkStylesResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json")
watch(() => props.primaryStyleId, async (newP, oldP) => {refreshStyles()})
watch(() => props.secondaryStyleId, async (newP, oldP) => {refreshStyles()})
const { data: stylesData }: PerkStylesResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
)
watch(
() => props.primaryStyleId,
async (newP, oldP) => {
refreshStyles()
}
)
watch(
() => props.secondaryStyleId,
async (newP, oldP) => {
refreshStyles()
}
)
function refreshStyles() {
for(let style of stylesData.value.styles) {
if(style.id == (props.primaryStyleId)) {
primaryStyle.value = style
}
if(style.id == (props.secondaryStyleId)) {
secondaryStyle.value = style
}
for (const style of stylesData.value.styles) {
if (style.id == props.primaryStyleId) {
primaryStyle.value = style
}
if (style.id == props.secondaryStyleId) {
secondaryStyle.value = style
}
}
}
refreshStyles()
</script>
<template>
<div style="display: flex;">
<div style="display: flex">
<div class="rune-holder">
<div class="rune-slot"><NuxtImg class="rune-style-img" style="margin: auto;" :src="CDRAGON_BASE + mapPath(primaryStyle.iconPath)" /></div>
<div class="rune-slot" v-for="slot in primaryStyle.slots.slice(0, 1)">
<NuxtImg width="48" v-for="perk in slot.perks" :class="'rune-img rune-keystone ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/>
</div>
<div class="rune-slot" v-for="slot in primaryStyle.slots.slice(1, 4)">
<NuxtImg width="48" v-for="perk in slot.perks" :class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/>
</div>
<div class="rune-slot">
<NuxtImg
class="rune-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(primaryStyle.iconPath)"
/>
</div>
<div v-for="slot in primaryStyle.slots.slice(0, 1)" class="rune-slot">
<NuxtImg
v-for="perk in slot.perks"
width="48"
:class="
'rune-img rune-keystone ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')
"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
/>
</div>
<div v-for="slot in primaryStyle.slots.slice(1, 4)" class="rune-slot">
<NuxtImg
v-for="perk in slot.perks"
width="48"
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
/>
</div>
</div>
<div class="rune-spacer-bar"></div>
<div class="rune-spacer-bar"/>
<div class="rune-holder" style="align-content: end">
<div class="rune-slot"><img style="margin: auto;" :src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)" /></div>
<div class="rune-slot" v-for="slot in secondaryStyle.slots.slice(1, 4)">
<NuxtImg width="48" v-for="perk in slot.perks" :class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/>
</div>
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)">
<div class="rune-slot">
<img style="margin: auto" :src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)" >
</div>
<div v-for="slot in secondaryStyle.slots.slice(1, 4)" class="rune-slot">
<NuxtImg
v-for="perk in slot.perks"
width="48"
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
/>
</div>
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)">
<img width="32" v-for="perk in slot.perks" :class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/>
</div> -->
</div>
</div>
</div>
</template>
<style>
.rune-holder {
/* align-content: end; */
justify-content: center;
/* align-content: end; */
justify-content: center;
}
.rune-slot {
width: calc(48*3px + 60px);
display: flex;
justify-content: space-between;
margin-top: 40px;
margin-bottom: 40px;
width: calc(48 * 3px + 60px);
display: flex;
justify-content: space-between;
margin-top: 40px;
margin-bottom: 40px;
}
.mini {
margin: auto;
width: calc(32*3px + 60px);
margin-top: 10px;
margin-bottom: 10px;
margin: auto;
width: calc(32 * 3px + 60px);
margin-top: 10px;
margin-bottom: 10px;
}
.rune-img {
max-width: 100%;
overflow: hidden;
filter: grayscale(1);
border: 1px var(--color-on-surface) solid;
border-radius:50%;
max-width: 100%;
overflow: hidden;
filter: grayscale(1);
border: 1px var(--color-on-surface) solid;
border-radius: 50%;
}
.rune-keystone {
border: none;
border: none;
}
.rune-activated {
filter: none;
filter: none;
}
.rune-spacer-bar {
margin-left: 20px;
margin-right: 20px;
border: 1px var(--color-on-surface) solid;
margin-left: 20px;
margin-right: 20px;
border: 1px var(--color-on-surface) solid;
}
@media only screen and (max-width: 650px) {
.rune-slot {
width: calc(24*3px + 30px);
margin-top: 20px;
margin-bottom: 20px;
}
.rune-img {
width: 24px;
height: 24px;
}
.rune-style-img {
width: 24px;
height: 24px;
}
.rune-spacer-bar {
margin-left: 10px;
margin-right: 10px;
}
.rune-slot {
width: calc(24 * 3px + 30px);
margin-top: 20px;
margin-bottom: 20px;
}
.rune-img {
width: 24px;
height: 24px;
}
.rune-style-img {
width: 24px;
height: 24px;
}
.rune-spacer-bar {
margin-left: 10px;
margin-right: 10px;
}
}
</style>
</style>

View File

@@ -1,128 +1,155 @@
<script setup lang="ts">
const props = defineProps<{
runes: Array<{count: number,
primaryStyle: number,
secondaryStyle: number,
selections: Array<number>,
pickrate: number}>
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))
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))
let { data: perks_data } : PerksResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/perks.json")
const { data: perks_data }: PerksResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
)
const perks = reactive(new Map())
for(let perk of perks_data.value) {
perks.set(perk.id, perk)
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
let { data: stylesData } : PerkStylesResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json")
watch(() => props.runes, (newRunes, oldRunes) => {
const { data: stylesData }: PerkStylesResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
)
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(let style of stylesData.value.styles) {
for(let rune of props.runes) {
if(style.id == rune.primaryStyle) {
primaryStyles.value[props.runes.indexOf(rune)] = style
for(let 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
}
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
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;"
:primaryStyleId="runes[currentlySelectedPage].primaryStyle"
:secondaryStyleId="runes[currentlySelectedPage].secondaryStyle"
:selectionIds="runes[currentlySelectedPage].selections" />
<div style="display: flex; margin-top: 20px; justify-content: center;">
<div v-for="(_, i) in runes" @click="runeSelect(i)">
<div :class="'rune-selector-entry ' + (i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')">
<div class="rs-styles-container">
<NuxtImg class="rs-style-img" v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
style="margin: auto;" :src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)" />
<NuxtImg class="rs-style-img" v-if="keystoneIds[i] != null && keystoneIds[i] != undefined"
width="34" :src="CDRAGON_BASE + ( mapPath(perks.get(keystoneIds[i]).iconPath))"/>
<NuxtImg class="rs-style-img" v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined"
style="margin: auto;" :src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)" />
</div>
<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" @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>
</div>
</div>
</template>
<style>
.rune-selector-entry {
width: 200px;
height: 120px;
width: 200px;
height: 120px;
margin-left: 10px;
margin-right: 10px;
margin-left: 10px;
margin-right: 10px;
border-radius: 8%;
border: 1px solid var(--color-on-surface);
border-radius: 8%;
border: 1px solid var(--color-on-surface);
}
.rune-selector-entry:hover {
cursor: pointer;
cursor: pointer;
}
.rune-selector-entry-selected {
background-color: var(--color-surface-darker);
background-color: var(--color-surface-darker);
}
.rs-styles-container {
display: flex;
margin-top: 20px;
display: flex;
margin-top: 20px;
}
.rs-pickrate {
text-align: center;
margin-top: -40px;
padding-bottom: 40px;
text-align: center;
margin-top: -40px;
padding-bottom: 40px;
}
@media only screen and (max-width: 650px) {
.rune-selector-entry {
width: 100px;
height: 60px;
.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;
}
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>
</style>

View File

@@ -1,38 +1,47 @@
<script lang="ts" setup>
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, plugins, scales } from 'chart.js'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
plugins,
scales
} from 'chart.js'
import { Bar } from 'vue-chartjs'
// Register
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
const props = defineProps<{
data: Array<{title:string, data: Array<{lane: LaneData, champion: Champion}>}>
data: Array<{ title: string; data: Array<{ lane: LaneData; champion: Champion }> }>
}>()
const labels: Array<string> = []
const pickrates: Array<number> = []
const pickrates: Array<number> = []
const images: Array<string> = []
const backgroundColors: Array<string> = []
const CHAMPION_CUT_THRESHOLD = 32
const TIER_COLORS = ["#ff7f7e", "#ffbf7f", "#ffdf80", "#feff7f", "#beff7f", "#7eff80"]
const TIER_COLORS = ['#ff7f7e', '#ffbf7f', '#ffdf80', '#feff7f', '#beff7f', '#7eff80']
let count = 0
let colorIndex = 0
for(let tier of props.data) {
for(let {champion: champion, lane: lane} of tier.data) {
if(count > CHAMPION_CUT_THRESHOLD) break;
for (const tier of props.data) {
for (const { champion: champion, lane: lane } of tier.data) {
if (count > CHAMPION_CUT_THRESHOLD) break
labels.push(champion.name)
pickrates.push(lane.pickrate * 100)
images.push(CDRAGON_BASE + mapPath(champion.squarePortraitPath))
backgroundColors.push(TIER_COLORS[colorIndex])
labels.push(champion.name)
pickrates.push(lane.pickrate * 100)
images.push(CDRAGON_BASE + mapPath(champion.squarePortraitPath))
backgroundColors.push(TIER_COLORS[colorIndex])
count++
}
colorIndex++
count++
}
colorIndex++
}
const chartData = ref({
labels: labels,
datasets: [
@@ -40,43 +49,45 @@ const chartData = ref({
label: 'Pickrate',
backgroundColor: backgroundColors,
barPercentage: 1.0,
data: pickrates,
},
],
data: pickrates
}
]
})
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
ticks: {
callback: (() => "")
}
ticks: {
callback: () => ''
}
}
},
plugins: {
legend: {
display: false
display: false
}
}
})
const chartPlugins = [{
id: "image-draw",
afterDraw: ((chart: any) => {
const ctx : CanvasRenderingContext2D = chart.ctx
var xAxis = chart.scales.x;
xAxis.ticks.forEach((value: any, index: number) => {
var x = xAxis.getPixelForTick(index)
var image = new Image()
image.src = images[index]
ctx.drawImage(image, x - 14, xAxis.bottom - 28, 28, 28)
})
})
}]
const chartPlugins = [
{
id: 'image-draw',
afterDraw: (chart: any) => {
const ctx: CanvasRenderingContext2D = chart.ctx
const xAxis = chart.scales.x
xAxis.ticks.forEach((value: any, index: number) => {
const x = xAxis.getPixelForTick(index)
const image = new Image()
image.src = images[index]
ctx.drawImage(image, x - 14, xAxis.bottom - 28, 28, 28)
})
}
}
]
</script>
<template>
<div>
<Bar :data="chartData" :options="chartOptions" :plugins="chartPlugins" />
</div>
</template>
</template>

View File

@@ -1,73 +1,82 @@
<script setup lang="ts">
defineProps<{
title: string
tier: Array<{champion: Champion, lane: LaneData}>
title: string
tier: Array<{ champion: Champion; lane: LaneData }>
}>()
</script>
<template>
<div style="display: flex;">
<div style="display: flex">
<h2 class="tierlist-tier-title">{{ title }}</h2>
<div class="tierlist-tier-container">
<NuxtLink v-for="{champion: champion} in tier" :to="'/champion/' + champion.alias.toLowerCase()">
<NuxtLink
v-for="{ champion: champion } in tier"
:to="'/champion/' + champion.alias.toLowerCase()"
>
<div class="champion-img-container">
<NuxtImg class="champion-img" :src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)" :alt="champion.name"/>
<NuxtImg
class="champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</NuxtLink>
</div>
</div>
</div>
</template>
<style>
.tierlist-tier-container {
width: 90%;
min-height: 122px;
width: 90%;
min-height: 122px;
display: grid;
grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px;
display: grid;
grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px;
align-items: center;
align-items: center;
margin: auto;
margin-top: 10px;
margin-bottom: 10px;
margin: auto;
margin-top: 10px;
margin-bottom: 10px;
}
.tierlist-tier-title {
font-size: 3.3rem;
margin-left: 20px;
margin-right: 20px;
margin-top: 40px;
font-weight: 300;
font-size: 3.3rem;
margin-left: 20px;
margin-right: 20px;
margin-top: 40px;
font-weight: 300;
}
.champion-img-container {
overflow: hidden; width: 120px; height: 120px;
border: 1px solid var(--color-surface);
overflow: hidden;
width: 120px;
height: 120px;
border: 1px solid var(--color-surface);
}
.champion-img-container:hover {
border: 1px solid var(--color-on-surface);
border: 1px solid var(--color-on-surface);
}
.champion-img {
width: 116px;
height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2);
width: 116px;
height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none;
user-select: none;
}
@media only screen and (max-width: 450px) {
.champion-img-container {
width: 80px;
height: 80px;
}
.champion-img {
width: 76px;
height: 76px;
}
.tierlist-tier-container {
grid-template-columns: repeat(auto-fit, 80px);
min-height: 82px;
}
.champion-img-container {
width: 80px;
height: 80px;
}
.champion-img {
width: 76px;
height: 76px;
}
.tierlist-tier-container {
grid-template-columns: repeat(auto-fit, 80px);
min-height: 82px;
}
}
</style>
</style>

View File

@@ -9,16 +9,26 @@ const props = defineProps({
<Title>{{ props.error.statusCode }} - BuildPath</Title>
</Head>
<Logo style="margin: auto; margin-top: 64px; margin-bottom: 64px;" />
<div style="margin:auto; width: fit-content; margin-top: 64px;">
<Logo style="margin: auto; margin-top: 64px; margin-bottom: 64px" />
<div style="margin: auto; width: fit-content; margin-top: 64px">
<h1>{{ props.error.statusCode }} Error</h1>
<h2>Something went wrong, sorry :(</h2>
<div style="margin-top: 64px;">
<h3 v-if="props.error.statusMessage != null
&& props.error.statusMessage != undefined
&& props.error.statusMessage != ''">Error message: {{ props.error.statusMessage }}</h3>
<h3 v-if="props.error.data != null && props.error.data != undefined">Error data: {{ props.error.data }}</h3>
<h3 v-if="props.error.cause != null && props.error.cause != undefined">Error cause: {{ props.error.cause }}</h3>
<div style="margin-top: 64px">
<h3
v-if="
props.error.statusMessage != null &&
props.error.statusMessage != undefined &&
props.error.statusMessage != ''
"
>
Error message: {{ props.error.statusMessage }}
</h3>
<h3 v-if="props.error.data != null && props.error.data != undefined">
Error data: {{ props.error.data }}
</h3>
<h3 v-if="props.error.cause != null && props.error.cause != undefined">
Error cause: {{ props.error.cause }}
</h3>
</div>
</div>
</template>
</template>

View File

@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View File

@@ -5,43 +5,39 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
routeRules: {
'/' : {prerender: false, swr: true},
'/champion/**' : {swr: true}
'/': { prerender: false, swr: true },
'/champion/**': { swr: true }
},
site: {
url: 'https://buildpath.win',
name: 'BuildPath',
description: 'BuildPath: a tool for League of Legends champions runes and build paths.',
defaultLocale: 'en', // not needed if you have @nuxtjs/i18n installed
defaultLocale: 'en' // not needed if you have @nuxtjs/i18n installed
},
sitemap: {
sources: [
'/api/routemap'
]
sources: ['/api/routemap']
},
app: {
head: {
htmlAttrs: {
lang: 'en',
lang: 'en'
},
link: [
{ rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'shortcut icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', sizes: "180x180", href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/site.webmanifest' },
],
meta: [
{name: "apple-mobile-web-app-title", content:"BuildPath"},
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/site.webmanifest' }
],
meta: [{ name: 'apple-mobile-web-app-title', content: 'BuildPath' }],
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1'
}
},
modules: ['@nuxt/image', '@nuxt/fonts', '@nuxtjs/seo', 'nuxt-umami'],
modules: ['@nuxt/image', '@nuxt/fonts', '@nuxtjs/seo', 'nuxt-umami', '@nuxt/eslint'],
umami: {
id: '98ef53ef-5fe1-4e29-a35e-56dc1283c212',
@@ -49,12 +45,12 @@ export default defineNuxtConfig({
autoTrack: true,
domains: ['buildpath.win'],
ignoreLocalhost: true,
enabled: true,
enabled: true
},
fonts: {
defaults: {
weights: [200, 400],
},
},
})
weights: [200, 400]
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,14 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@nuxt/eslint": "^1.12.1",
"@nuxt/fonts": "^0.11.3",
"@nuxt/image": "^1.10.0",
"@nuxtjs/seo": "^3.0.3",
@@ -24,7 +29,15 @@
"vue-router": "latest"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vue/compiler-sfc": "^3.5.13",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-nuxt": "^4.0.0",
"eslint-plugin-vue": "^10.7.0",
"prettier": "^3.8.0",
"typescript": "^5.7.2",
"vue-tsc": "^2.1.10"
}

View File

@@ -1,44 +1,44 @@
<script setup lang="ts">
</script>
<script setup lang="ts"></script>
<template>
<Head>
<Title>About</Title>
</Head>
<Head>
<Title>About</Title>
</Head>
<div class="about-main-content">
<div class="about-main-content">
<NavBar :tierlist-list="true" />
<div style="width: fit-content; margin: auto;">
<Logo />
<div style="margin-top: 20px;">
<h1>About</h1>
<h3 style="margin-top: 20px;">BuildPath: a tool for League of Legends champions runes and build paths.</h3>
<h3 style="margin-top: 10px;">Copyright (C) Valentin Haudiquet (@vhaudiquet)</h3>
<h3 style="margin-top: 20px;">Acknowledgments:</h3>
<h3>- Sarah Emery, for the feedback on the designs and code</h3>
<h3>- Martin Andrieux, for the nice algorithms :)</h3>
<h3>- Paul Chaurand, for the feedback on the league data organization</h3>
<h3>- Nathan Mérillon, for the tierlists ideas</h3>
<h3>- Jean-Baptiste Döderlein, for the feedback on the mobile design</h3>
<h3 style="margin-top: 20px;">Libraries used:</h3>
<h3>Vue.JS, Nuxt.JS, Chart.JS, svg-dom-arrows</h3>
<h2 style="font-size: 1rem; font-weight: 200; margin-top: 25px; max-width: 800px;">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of
Riot Games or anyone officially involved in producing or managing Riot Games properties.
Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc.
</h2>
</div>
</div>
<div style="width: fit-content; margin: auto">
<Logo />
<div style="margin-top: 20px">
<h1>About</h1>
<h3 style="margin-top: 20px">
BuildPath: a tool for League of Legends champions runes and build paths.
</h3>
<h3 style="margin-top: 10px">Copyright (C) Valentin Haudiquet (@vhaudiquet)</h3>
<h3 style="margin-top: 20px">Acknowledgments:</h3>
<h3>- Sarah Emery, for the feedback on the designs and code</h3>
<h3>- Martin Andrieux, for the nice algorithms :)</h3>
<h3>- Paul Chaurand, for the feedback on the league data organization</h3>
<h3>- Nathan Mérillon, for the tierlists ideas</h3>
<h3>- Jean-Baptiste Döderlein, for the feedback on the mobile design</h3>
<h3 style="margin-top: 20px">Libraries used:</h3>
<h3>Vue.JS, Nuxt.JS, Chart.JS, svg-dom-arrows</h3>
<h2 style="font-size: 1rem; font-weight: 200; margin-top: 25px; max-width: 800px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot
Games, Inc.
</h2>
</div>
</div>
</div>
</template>
<style scoped>
.about-main-content {
display: flex;
margin-top: 30px;
display: flex;
margin-top: 30px;
}
</style>

View File

@@ -2,99 +2,112 @@
const route = useRoute()
const championAlias = route.params.alias as string
const { data : championData } : {data : Ref<ChampionData>} = await useFetch("/api/champion/" + championAlias.toLowerCase())
const { data: championData }: { data: Ref<ChampionData> } = await useFetch(
'/api/champion/' + championAlias.toLowerCase()
)
const championId = championData.value.id
// Prefetch home page for faster navigation
useHead({
link: [
{ rel: 'prefetch', href: '/' }
]
link: [{ rel: 'prefetch', href: '/' }]
})
defineOgImageComponent('Champion', {
title: championData.value.name,
id: championId,
winrate: championData.value.winrate,
pickrate: championData.value.pickrate,
gameCount: championData.value.gameCount,
title: championData.value.name,
id: championId,
winrate: championData.value.winrate,
pickrate: championData.value.pickrate,
gameCount: championData.value.gameCount
})
useSeoMeta({
title: championData.value.name,
description: 'Build path and runes for ' + championData.value.name + ' on BuildPath'
title: championData.value.name,
description: 'Build path and runes for ' + championData.value.name + ' on BuildPath'
})
const laneState = ref(0)
const state = ref("runes")
const state = ref('runes')
const lane = ref(championData.value.lanes[laneState.value])
function updateState(newState : string, newLane : number) {
state.value = newState
laneState.value = newLane
lane.value = championData.value.lanes[laneState.value]
function updateState(newState: string, newLane: number) {
state.value = newState
laneState.value = newLane
lane.value = championData.value.lanes[laneState.value]
}
</script>
<template>
<Head>
<Title>{{ championData.name }}</Title>
</Head>
<Head>
<Title>{{ championData.name }}</Title>
</Head>
<div id="alias-content-wrapper">
<NavBar :champion-name="championData.name"
:champion-lanes="championData.lanes"
@state-change="updateState"/>
<div id="alias-content-wrapper">
<NavBar
:champion-name="championData.name"
:champion-lanes="championData.lanes"
@state-change="updateState"
/>
<div id="champion-content">
<ChampionTitle id="champion-title" v-if="championData.gameCount > 0"
:champion-id="championId" :winrate="lane.winrate"
:pickrate="lane.pickrate" :game-count="lane.count" />
<RuneSelector v-if="state == 'runes' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px;"
:runes="lane.runes!!" />
<ItemViewer v-if="state == 'items' && championData.gameCount > 0"
style="margin:auto; margin-top: 40px;"
:builds="lane.builds!!" />
<ItemTree v-if="state == 'alternatives' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px; width: fit-content;"
:tree="lane.builds!!.tree" />
<h2 v-if="championData.gameCount == 0"
style="margin: auto; margin-top: 20px; width: fit-content;">
Sorry, there is no data for this champion :(
</h2>
</div>
<ChampionTitle
v-if="championData.gameCount > 0"
id="champion-title"
:champion-id="championId"
:winrate="lane.winrate"
:pickrate="lane.pickrate"
:game-count="lane.count"
/>
<RuneSelector
v-if="state == 'runes' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px"
:runes="lane.runes!!"
/>
<ItemViewer
v-if="state == 'items' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px"
:builds="lane.builds!!"
/>
<ItemTree
v-if="state == 'alternatives' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px; width: fit-content"
:tree="lane.builds!!.tree"
/>
<h2
v-if="championData.gameCount == 0"
style="margin: auto; margin-top: 20px; width: fit-content"
>
Sorry, there is no data for this champion :(
</h2>
</div>
</div>
</template>
<style>
#alias-content-wrapper {
display: flex;
/* min-height: 100vh; */
align-items: stretch;
width: 100%;
display: flex;
/* min-height: 100vh; */
align-items: stretch;
width: 100%;
overflow: hidden;
overflow: hidden;
}
#champion-content {
margin-top: 64px;
margin-left: 39px;
width: 100%;
margin-top: 64px;
margin-left: 39px;
width: 100%;
}
@media only screen and (max-width: 650px) {
#champion-content {
margin: auto;
margin-top: 10px;
}
#champion-title {
margin:auto;
}
#champion-content {
margin: auto;
margin-top: 10px;
}
#champion-title {
margin: auto;
}
}
@media only screen and (max-width: 1200px) {
#alias-content-wrapper {
flex-direction: column;
padding-bottom: 120px;
margin-top: 20px;
}
#alias-content-wrapper {
flex-direction: column;
padding-bottom: 120px;
margin-top: 20px;
}
}
</style>

View File

@@ -1,63 +1,60 @@
<script setup lang="ts">
import { POSITIONS, LANE_IMAGES, POSITIONS_STR } from '~/utils/cdragon';
import { POSITIONS, LANE_IMAGES, POSITIONS_STR } from '~/utils/cdragon'
</script>
<template>
<Head>
<Title>Home</Title>
</Head>
<Head>
<Title>Home</Title>
</Head>
<div class="index-main-content">
<div class="index-main-content">
<NavBar :tierlist-list="true" />
<ChampionSelector class="index-champion-selector"/>
</div>
<ChampionSelector class="index-champion-selector" />
</div>
</template>
<style>
.index-main-content {
display: flex;
margin-top: 50px;
display: flex;
margin-top: 50px;
}
.index-champion-selector {
width: 80%;
width: 80%;
}
.index-tierlist-info-container {
margin: auto;
margin-top: 0px;
width: fit-content;
margin: auto;
margin-top: 0px;
width: fit-content;
}
#index-tierlists {
margin: auto;
margin-top: 10px;
width: fit-content;
margin: auto;
margin-top: 10px;
width: fit-content;
}
@media only screen and (max-width: 1240px) {
.index-main-content {
flex-direction: column;
margin-top: 5px;
}
.index-champion-selector {
width: 100%;
}
.index-tierlist-info-container {
display: flex;
margin-bottom: 50px;
}
.index-main-content {
flex-direction: column;
margin-top: 5px;
}
.index-champion-selector {
width: 100%;
}
.index-tierlist-info-container {
display: flex;
margin-bottom: 50px;
}
}
@media only screen and (max-width: 500px) {
#index-statp {
display: none;
}
.index-tierlist-info-container {
margin-bottom: 10px;
margin-left: 10px;
#index-statp {
display: none;
}
.index-tierlist-info-container {
margin-bottom: 10px;
margin-left: 10px;
display: none;
}
display: none;
}
}
</style>

View File

@@ -1,105 +1,129 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon';
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
const route = useRoute()
const lane = route.params.lane as string
const {data: championsData} : ChampionsResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json")
const { data: championsData }: ChampionsResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const {data: championsLanes} : {data: Ref<Array<ChampionData>>} = await useFetch("/api/champions")
const infoMap : Map<string, ChampionData> = new Map()
for(let champion of championsLanes.value) {
infoMap.set(champion.alias, champion)
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
await useFetch('/api/champions')
const infoMap: Map<string, ChampionData> = new Map()
for (const champion of championsLanes.value) {
infoMap.set(champion.alias, champion)
}
const champions = championsData.value.slice(1).filter((champion) => {
const championData : ChampionData | undefined = infoMap.get(champion.alias.toLowerCase())
if(championData == undefined) return false;
const champions = championsData.value.slice(1).filter(champion => {
const championData: ChampionData | undefined = infoMap.get(champion.alias.toLowerCase())
if (championData == undefined) return false
const lanes = championData.lanes
return lanes.reduce((acc : boolean, current : {data:string, count:number}) =>
acc || (current.data.toLowerCase() == lane.toLowerCase()), false)
const lanes = championData.lanes
return lanes.reduce(
(acc: boolean, current: { data: string; count: number }) =>
acc || current.data.toLowerCase() == lane.toLowerCase(),
false
)
})
const allChampions = champions.map((x) => {
const championData : ChampionData = infoMap.get(x.alias.toLowerCase())!!
const allChampions = champions
.map(x => {
const championData: ChampionData = infoMap.get(x.alias.toLowerCase())!
let currentLane = championData.lanes[0]
for(let championLane of championData.lanes) {
if(championLane.data.toLowerCase() == lane.toLowerCase()) {
currentLane = championLane
break
}
for (const championLane of championData.lanes) {
if (championLane.data.toLowerCase() == lane.toLowerCase()) {
currentLane = championLane
break
}
}
return {lane: currentLane, champion: x}
}).sort((a, b) => b.lane.pickrate - a.lane.pickrate)
return { lane: currentLane, champion: x }
})
.sort((a, b) => b.lane.pickrate - a.lane.pickrate)
const p_min = Math.min(...allChampions.map((x) => x.lane.pickrate))
const p_max = Math.max(...allChampions.map((x) => x.lane.pickrate))
const p_min = Math.min(...allChampions.map(x => x.lane.pickrate))
const p_max = Math.max(...allChampions.map(x => x.lane.pickrate))
allChampions.map((x) => (x as {lane: LaneData, champion: Champion, scaledPickrate: number}).scaledPickrate = (x.lane.pickrate - p_min)/(p_max - p_min))
allChampions.map(
x =>
((x as { lane: LaneData; champion: Champion; scaledPickrate: number }).scaledPickrate =
(x.lane.pickrate - p_min) / (p_max - p_min))
)
allChampions.sort((a, b) => b.lane.pickrate - a.lane.pickrate)
function tierFromScaledPickrate(min: number, max: number) {
return (allChampions as Array<{lane: LaneData, champion: Champion, scaledPickrate: number}>)
.filter(({scaledPickrate: scaledPickrate}) => {
return scaledPickrate > min && scaledPickrate <= max
})
return (
allChampions as Array<{ lane: LaneData; champion: Champion; scaledPickrate: number }>
).filter(({ scaledPickrate: scaledPickrate }) => {
return scaledPickrate > min && scaledPickrate <= max
})
}
const tiers: Array<{title:string, data: Array<{lane: LaneData, champion: Champion}>}> = []
tiers.push({title: "S", data: tierFromScaledPickrate(0.9, 1)})
tiers.push({title: "A", data: tierFromScaledPickrate(0.7, 0.9)})
tiers.push({title: "B", data: tierFromScaledPickrate(0.5, 0.7)})
tiers.push({title: "C", data: tierFromScaledPickrate(0.3, 0.5)})
tiers.push({title: "D", data: tierFromScaledPickrate(0.1, 0.3)})
tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)})
const tiers: Array<{ title: string; data: Array<{ lane: LaneData; champion: Champion }> }> = []
tiers.push({ title: 'S', data: tierFromScaledPickrate(0.9, 1) })
tiers.push({ title: 'A', data: tierFromScaledPickrate(0.7, 0.9) })
tiers.push({ title: 'B', data: tierFromScaledPickrate(0.5, 0.7) })
tiers.push({ title: 'C', data: tierFromScaledPickrate(0.3, 0.5) })
tiers.push({ title: 'D', data: tierFromScaledPickrate(0.1, 0.3) })
tiers.push({ title: 'F', data: tierFromScaledPickrate(0, 0.1) })
</script>
<template>
<Head>
<Title>Tierlist for {{ POSITIONS_STR[lanePositionToIndex(lane)] }}</Title>
</Head>
<Head>
<Title>Tierlist for {{ POSITIONS_STR[lanePositionToIndex(lane)] }}</Title>
</Head>
<div style="display: flex; min-height: 100vh; align-items: stretch; width: 100%;">
<NavBar :tierlist-list="true" />
<div style="display: flex; min-height: 100vh; align-items: stretch; width: 100%">
<NavBar :tierlist-list="true" />
<div id="tierlist-container" style="margin-left: 10px; width: 100%; overflow-y: scroll;">
<div id="tierlist-container" style="margin-left: 10px; width: 100%; overflow-y: scroll">
<div
style="
margin-left: 0px;
margin-top: 20px;
display: flex;
margin-bottom: 30px;
align-items: center;
"
>
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300">Tierlist for</h1>
<NuxtImg
format="webp"
style="margin-left: 10px"
width="50"
height="50"
:src="LANE_IMAGES[lanePositionToIndex(lane)]"
/>
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300">
{{ POSITIONS_STR[lanePositionToIndex(lane)] }}
</h1>
</div>
<div style="margin-left: 0px; margin-top: 20px; display: flex; margin-bottom: 30px; align-items: center">
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300;">Tierlist for</h1>
<NuxtImg format="webp" style="margin-left: 10px;"
width="50" height="50"
:src="LANE_IMAGES[lanePositionToIndex(lane)]" />
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300;">{{ POSITIONS_STR[lanePositionToIndex(lane)] }}</h1>
</div>
<TierlistTier v-for="tier in tiers" :title="tier.title" :tier="tier.data" />
<TierlistTier v-for="tier in tiers" :title="tier.title" :tier="tier.data" />
<TierlistChart id="chart" :data="tiers" />
</div>
<TierlistChart id="chart" :data="tiers" />
</div>
</div>
</template>
<style scoped>
#chart {
margin-left: 100px;
margin-right: 100px;
margin-bottom: 100px;
margin-top: 40px
margin-left: 100px;
margin-right: 100px;
margin-bottom: 100px;
margin-top: 40px;
}
@media only screen and (max-width: 450px) {
#chart {
margin-left: 2px;
margin-right: 12px;
margin-bottom: 40px;
margin-top: 40px;
}
#tierlist-container {
padding-bottom: 120px;
}
#chart {
margin-left: 2px;
margin-right: 12px;
margin-bottom: 40px;
margin-top: 40px;
}
#tierlist-container {
padding-bottom: 120px;
}
}
</style>
</style>

View File

@@ -18,4 +18,4 @@
"theme_color": "#312E2C",
"background_color": "#312E2C",
"display": "standalone"
}
}

View File

@@ -1,19 +1,19 @@
import { MongoClient } from 'mongodb';
import {connectToDatabase, fetchLatestPatch} from '../../utils/mongo'
import type { MongoClient } from 'mongodb'
import { connectToDatabase, fetchLatestPatch } from '../../utils/mongo'
async function championInfos(client: MongoClient, patch: string, championAlias: string) {
const database = client.db("champions");
const collection = database.collection(patch);
const query = { alias:championAlias };
const championInfo = (await collection.findOne(query)) as unknown as ChampionData;
return championInfo
const database = client.db('champions')
const collection = database.collection(patch)
const query = { alias: championAlias }
const championInfo = (await collection.findOne(query)) as unknown as ChampionData
return championInfo
}
export default defineEventHandler(async (event) => {
const championAlias = (getRouterParam(event, "alias") as string).toLowerCase()
const client = await connectToDatabase();
const latestPatch = await fetchLatestPatch(client);
const data = await championInfos(client, latestPatch, championAlias);
await client.close()
return data
export default defineEventHandler(async event => {
const championAlias = (getRouterParam(event, 'alias') as string).toLowerCase()
const client = await connectToDatabase()
const latestPatch = await fetchLatestPatch(client)
const data = await championInfos(client, latestPatch, championAlias)
await client.close()
return data
})

View File

@@ -1,28 +1,30 @@
import { MongoClient } from 'mongodb';
import {connectToDatabase, fetchLatestPatch} from '../utils/mongo'
import type { MongoClient } from 'mongodb'
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
async function champions(client: MongoClient, patch: string) {
const database = client.db("champions");
const collection = database.collection(patch);
const data : Array<ChampionData> = (await collection.find().toArray()) as unknown as Array<ChampionData>
data.map((x) => {
if(x.lanes != undefined && x.lanes != null) {
for(let lane of x.lanes) {
delete lane.builds
delete lane.runes
}
}
})
return data
const database = client.db('champions')
const collection = database.collection(patch)
const data: Array<ChampionData> = (await collection
.find()
.toArray()) as unknown as Array<ChampionData>
data.map(x => {
if (x.lanes != undefined && x.lanes != null) {
for (const lane of x.lanes) {
delete lane.builds
delete lane.runes
}
}
})
return data
}
export default defineEventHandler(async (_) => {
const client = await connectToDatabase();
const latestPatch = await fetchLatestPatch(client);
export default defineEventHandler(async _ => {
const client = await connectToDatabase()
const latestPatch = await fetchLatestPatch(client)
const data = await champions(client, latestPatch);
await client.close()
const data = await champions(client, latestPatch)
return data
await client.close()
return data
})

View File

@@ -1,17 +1,20 @@
import { CDRAGON_BASE } from "~/utils/cdragon";
import { CDRAGON_BASE } from '~/utils/cdragon'
async function championRoutes() {
const championsData : Array<Champion> = await
(await fetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json")).json()
let routes : Array<string> = []
for(let champion of championsData) {
routes.push("/champion/" + champion.alias.toLowerCase())
}
return routes
const championsData: Array<Champion> = await (
await fetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
).json()
const routes: Array<string> = []
for (const champion of championsData) {
routes.push('/champion/' + champion.alias.toLowerCase())
}
return routes
}
export default defineEventHandler(async (_) => {
const data = await championRoutes();
return data
export default defineEventHandler(async _ => {
const data = await championRoutes()
return data
})

View File

@@ -1,19 +1,19 @@
import { MongoClient } from 'mongodb';
import {connectToDatabase, fetchLatestPatch} from '../utils/mongo'
import type { MongoClient } from 'mongodb'
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
async function fetchGameCount(client : MongoClient, patch: string) {
const database = client.db("matches");
const matches = database.collection(patch);
const count = await matches.countDocuments()
return count
async function fetchGameCount(client: MongoClient, patch: string) {
const database = client.db('matches')
const matches = database.collection(patch)
const count = await matches.countDocuments()
return count
}
export default defineEventHandler(async (_) => {
const client = await connectToDatabase();
const latestPatch = await fetchLatestPatch(client);
const gameCount = await fetchGameCount(client, latestPatch)
await client.close()
export default defineEventHandler(async _ => {
const client = await connectToDatabase()
const latestPatch = await fetchLatestPatch(client)
const gameCount = await fetchGameCount(client, latestPatch)
return {patch: latestPatch, count: gameCount}
await client.close()
return { patch: latestPatch, count: gameCount }
})

View File

@@ -1,21 +1,25 @@
import { MongoClient } from 'mongodb'
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if(process.env.MONGO_URI != undefined && process.env.MONGO_URI != null && process.env.MONGO_URI != "") {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
}
async function fetchLatestPatch(client : MongoClient) {
const database = client.db("patches");
const patches = database.collection("patches");
const latestPatch = await patches.find().limit(1).sort({date:-1}).next()
return latestPatch!!.patch as string
async function fetchLatestPatch(client: MongoClient) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return latestPatch!.patch as string
}
export {connectToDatabase, fetchLatestPatch}
export { connectToDatabase, fetchLatestPatch }

View File

@@ -1,74 +1,73 @@
declare global {
/**
* Represents an item in the build tree
*/
interface ItemTree {
count: number;
data: number;
children: ItemTree[];
}
/**
* Represents an item in the build tree
*/
interface ItemTree {
count: number
data: number
children: ItemTree[]
}
/**
* Represents champion build information
*/
interface Builds {
start: Array<{count: number, data: number}>;
tree: ItemTree;
bootsFirst: number;
boots: Array<{count: number, data: number}>;
lateGame: Array<{count: number, data: number}>;
suppItems?: Array<{count: number, data: number}>;
}
/**
* Represents champion build information
*/
interface Builds {
start: Array<{ count: number; data: number }>
tree: ItemTree
bootsFirst: number
boots: Array<{ count: number; data: number }>
lateGame: Array<{ count: number; data: number }>
suppItems?: Array<{ count: number; data: number }>
}
/**
* Represents a rune configuration
*/
interface Rune {
count: number;
primaryStyle: number;
secondaryStyle: number;
selections: number[];
pickrate: number;
}
/**
* Represents a rune configuration
*/
interface Rune {
count: number
primaryStyle: number
secondaryStyle: number
selections: number[]
pickrate: number
}
/**
* Represents lane-specific champion data
*/
interface LaneData {
data: string;
count: number;
winningMatches: number;
losingMatches: number;
winrate: number;
pickrate: number;
runes?: Rune[];
builds?: Builds;
}
/**
* Represents lane-specific champion data
*/
interface LaneData {
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
runes?: Rune[]
builds?: Builds
}
/**
* Represents complete champion data
*/
interface ChampionData {
id: number;
name: string;
alias: string;
gameCount: number;
winrate: number;
pickrate: number;
lanes: LaneData[];
}
/**
* Represents complete champion data
*/
interface ChampionData {
id: number
name: string
alias: string
gameCount: number
winrate: number
pickrate: number
lanes: LaneData[]
}
/**
* Champion summary from CDragon
*/
interface ChampionSummary {
id: number;
name: string;
alias: string;
squarePortraitPath: string;
// Add other relevant fields as needed
}
/**
* Champion summary from CDragon
*/
interface ChampionSummary {
id: number
name: string
alias: string
squarePortraitPath: string
// Add other relevant fields as needed
}
}
export {};
export {}

View File

@@ -1,44 +1,44 @@
declare global {
type ChampionsResponse = {
data: Ref<Array<Champion>>
}
type ChampionResponse = {
data: Ref<ChampionFull>
}
type Champion = {
name: string
alias: string
squarePortraitPath: string
}
type ChampionFull = {
name: string
alias: string
squarePortraitPath: string
title: string
}
type ItemResponse = {
data: Ref<Array<Item>>
}
type Item = {
id: number
}
type PerksResponse = {
data: Ref<Array<Perk>>
}
type Perk = {
id: number
name: string
iconPath: string
}
type PerkStylesResponse = {
data: Ref<{styles: Array<PerkStyle>}>
}
type PerkStyle = {
id: number
name: string
iconPath: string
slots: Array<{perks:Array<number>}>
}
type ChampionsResponse = {
data: Ref<Array<Champion>>
}
type ChampionResponse = {
data: Ref<ChampionFull>
}
type Champion = {
name: string
alias: string
squarePortraitPath: string
}
type ChampionFull = {
name: string
alias: string
squarePortraitPath: string
title: string
}
type ItemResponse = {
data: Ref<Array<Item>>
}
type Item = {
id: number
}
type PerksResponse = {
data: Ref<Array<Perk>>
}
type Perk = {
id: number
name: string
iconPath: string
}
type PerkStylesResponse = {
data: Ref<{ styles: Array<PerkStyle> }>
}
type PerkStyle = {
id: number
name: string
iconPath: string
slots: Array<{ perks: Array<number> }>
}
}
export {}
export {}

View File

@@ -1,35 +1,55 @@
const CDRAGON_BASE = "https://raw.communitydragon.org/latest/"
const CDRAGON_BASE = 'https://raw.communitydragon.org/latest/'
/* Lanes */
const POSITIONS = ["top", "jungle", "middle", "bottom", "utility"]
const POSITIONS_STR = ["top", "jungle", "mid", "bot", "support"]
const LANE_IMAGES = Array(5).fill("").map((_, index) => "/img/lanes/icon-position-" + POSITIONS[index] + ".png")
const LANE_IMAGES_HOVER = Array(5).fill("").map((_, index) => "/img/lanes/icon-position-" + POSITIONS[index] + "-hover.png")
const LANE_IMAGES_SELECTED = Array(5).fill("").map((_, index) => "/img/lanes/icon-position-" + POSITIONS[index] + "-blue.png")
function laneIndexToPosition(index : number) {
switch(index) {
case 0: return "top"
case 1: return "jungle"
case 2: return "middle"
case 3: return "bottom"
case 4: return "utility"
}
return null
const POSITIONS = ['top', 'jungle', 'middle', 'bottom', 'utility']
const POSITIONS_STR = ['top', 'jungle', 'mid', 'bot', 'support']
const LANE_IMAGES = Array(5)
.fill('')
.map((_, index) => '/img/lanes/icon-position-' + POSITIONS[index] + '.png')
const LANE_IMAGES_HOVER = Array(5)
.fill('')
.map((_, index) => '/img/lanes/icon-position-' + POSITIONS[index] + '-hover.png')
const LANE_IMAGES_SELECTED = Array(5)
.fill('')
.map((_, index) => '/img/lanes/icon-position-' + POSITIONS[index] + '-blue.png')
function laneIndexToPosition(index: number) {
switch (index) {
case 0:
return 'top'
case 1:
return 'jungle'
case 2:
return 'middle'
case 3:
return 'bottom'
case 4:
return 'utility'
}
return null
}
function lanePositionToIndex(position : string) {
const p = position.toLowerCase()
for(let i = 0; i < POSITIONS.length; i++) {
if(p == POSITIONS[i]) return i;
}
return -1;
function lanePositionToIndex(position: string) {
const p = position.toLowerCase()
for (let i = 0; i < POSITIONS.length; i++) {
if (p == POSITIONS[i]) return i
}
return -1
}
function mapPath(assetPath : string) {
if(assetPath === undefined || assetPath === null) return ""
return assetPath.toLowerCase().replace("/lol-game-data/assets/", "plugins/rcp-be-lol-game-data/global/default/")
function mapPath(assetPath: string) {
if (assetPath === undefined || assetPath === null) return ''
return assetPath
.toLowerCase()
.replace('/lol-game-data/assets/', 'plugins/rcp-be-lol-game-data/global/default/')
}
export {
mapPath, CDRAGON_BASE, laneIndexToPosition, lanePositionToIndex,
POSITIONS, LANE_IMAGES, LANE_IMAGES_HOVER, LANE_IMAGES_SELECTED, POSITIONS_STR
}
export {
mapPath,
CDRAGON_BASE,
laneIndexToPosition,
lanePositionToIndex,
POSITIONS,
LANE_IMAGES,
LANE_IMAGES_HOVER,
LANE_IMAGES_SELECTED,
POSITIONS_STR
}

View File

@@ -8,15 +8,18 @@
* @param wait Time in milliseconds to wait before calling the function
* @returns Debounced function
*/
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null
return function(...args: Parameters<T>): void {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), wait);
};
return function (...args: Parameters<T>): void {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => func(...args), wait)
}
}
/**
@@ -26,12 +29,12 @@ export function debounce<T extends (...args: any[]) => any>(func: T, wait: numbe
* @returns Parsed JSON or default value
*/
export function safeJsonParse<T>(data: string, defaultValue: T): T {
try {
return JSON.parse(data) as T;
} catch (error) {
console.error('JSON parse error:', error);
return defaultValue;
}
try {
return JSON.parse(data) as T
} catch (error) {
console.error('JSON parse error:', error)
return defaultValue
}
}
/**
@@ -41,7 +44,7 @@ export function safeJsonParse<T>(data: string, defaultValue: T): T {
* @returns Formatted percentage string
*/
export function formatPercentage(value: number, decimals: number = 0): string {
return (value * 100).toFixed(decimals) + '%';
return (value * 100).toFixed(decimals) + '%'
}
/**
@@ -50,8 +53,8 @@ export function formatPercentage(value: number, decimals: number = 0): string {
* @returns Capitalized string
*/
export function capitalize(str: string): string {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
if (!str) return ''
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
/**
@@ -60,14 +63,14 @@ export function capitalize(str: string): string {
* @returns Readable lane name
*/
export function getLaneName(position: string): string {
const laneMap: Record<string, string> = {
'top': 'Top',
'jungle': 'Jungle',
'middle': 'Middle',
'bottom': 'Bottom',
'utility': 'Support'
};
return laneMap[position.toLowerCase()] || position;
const laneMap: Record<string, string> = {
top: 'Top',
jungle: 'Jungle',
middle: 'Middle',
bottom: 'Bottom',
utility: 'Support'
}
return laneMap[position.toLowerCase()] || position
}
/**
@@ -76,7 +79,7 @@ export function getLaneName(position: string): string {
* @returns Full image URL
*/
export function getChampionImageUrl(championAlias: string): string {
return `/img/champions/${championAlias.toLowerCase()}.png`;
return `/img/champions/${championAlias.toLowerCase()}.png`
}
/**
@@ -85,7 +88,7 @@ export function getChampionImageUrl(championAlias: string): string {
* @returns Full item image URL
*/
export function getItemImageUrl(itemId: number): string {
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/items/${itemId}.png`;
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/items/${itemId}.png`
}
/**
@@ -94,7 +97,7 @@ export function getItemImageUrl(itemId: number): string {
* @returns Full rune image URL
*/
export function getRuneImageUrl(runeId: number): string {
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/perks/${runeId}.png`;
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/perks/${runeId}.png`
}
/**
@@ -103,12 +106,12 @@ export function getRuneImageUrl(runeId: number): string {
* @returns Formatted string
*/
export function formatLargeNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
/**
@@ -117,7 +120,7 @@ export function formatLargeNumber(num: number): string {
* @returns Cloned object
*/
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
return JSON.parse(JSON.stringify(obj))
}
/**
@@ -126,11 +129,11 @@ export function deepClone<T>(obj: T): T {
* @returns True if value is empty
*/
export function isEmpty(value: any): boolean {
if (value === null || value === undefined) return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
return false;
if (value === null || value === undefined) return true
if (typeof value === 'string' && value.trim() === '') return true
if (Array.isArray(value) && value.length === 0) return true
if (typeof value === 'object' && Object.keys(value).length === 0) return true
return false
}
/**
@@ -139,9 +142,9 @@ export function isEmpty(value: any): boolean {
* @returns CSS color class
*/
export function getWinrateColor(winrate: number): string {
if (winrate > 0.55) return 'text-green-500';
if (winrate < 0.45) return 'text-red-500';
return 'text-yellow-500';
if (winrate > 0.55) return 'text-green-500'
if (winrate < 0.45) return 'text-red-500'
return 'text-yellow-500'
}
/**
@@ -150,15 +153,15 @@ export function getWinrateColor(winrate: number): string {
* @returns Formatted duration string
*/
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
if (hours > 0) {
return `${hours}h ${minutes % 60}m`
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
} else {
return `${seconds}s`
}
}