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

View File

@@ -8,7 +8,56 @@ permissions:
packages: write packages: write
jobs: jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: https://gitea.com/actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies for frontend
working-directory: ./frontend
run: npm ci
- name: Lint frontend
working-directory: ./frontend
run: npm run lint
- name: Check formatting for frontend
working-directory: ./frontend
run: npm run format:check
- name: Install dependencies for match_collector
working-directory: ./match_collector
run: npm ci
- name: Lint match_collector
working-directory: ./match_collector
run: npm run lint
- name: Check formatting for match_collector
working-directory: ./match_collector
run: npm run format:check
- name: Install dependencies for patch_detector
working-directory: ./patch_detector
run: npm ci
- name: Lint patch_detector
working-directory: ./patch_detector
run: npm run lint
- name: Check formatting for patch_detector
working-directory: ./patch_detector
run: npm run format:check
build-and-push-images: build-and-push-images:
needs: lint-and-format
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository

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 # BuildPath - Nuxt

View File

@@ -3,7 +3,7 @@ useSeoMeta({
title: 'BuildPath', title: 'BuildPath',
ogTitle: 'BuildPath', ogTitle: 'BuildPath',
description: 'BuildPath: a tool for League of Legends champions runes and build paths.', 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> </script>

View File

@@ -1,14 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'); @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 { :root {
--color-surface: #312E2C; --color-surface: #312e2c;
--color-on-surface: #B7B8E1; --color-on-surface: #b7b8e1;
--color-surface-darker: #1f1d1c; --color-surface-darker: #1f1d1c;
} }
/* Font setting */ /* Font setting */
h1,h2,h3,h4,h5,h6,p,a,input[type=text] { h1,
font-family: "Inter", sans-serif; h2,
h3,
h4,
h5,
h6,
p,
a,
input[type='text'] {
font-family: 'Inter', sans-serif;
font-optical-sizing: auto; font-optical-sizing: auto;
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
@@ -17,7 +25,14 @@ h1,h2,h3,h4,h5,h6,p,a,input[type=text] {
} }
/* Default margins to none */ /* Default margins to none */
h1,h2,h3,h4,h5,h6,p,a { h1,
h2,
h3,
h4,
h5,
h6,
p,
a {
margin: 0px; margin: 0px;
} }

View File

@@ -1,116 +1,130 @@
<script setup lang="ts"> <script setup lang="ts">
import { debounce, isEmpty } from '~/utils/helpers'; import { debounce, isEmpty } from '~/utils/helpers'
// Constants // Constants
const CDRAGON_CHAMPIONS_URL = CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json"; const CDRAGON_CHAMPIONS_URL =
const CHAMPIONS_API_URL = "/api/champions"; CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
const CHAMPIONS_API_URL = '/api/champions'
// State // 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', key: 'champions-data',
lazy: false, lazy: false,
server: false // Disable server-side fetching to avoid hydration issues 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', key: 'champions-lanes',
lazy: false, lazy: false,
server: false // Disable server-side fetching to avoid hydration issues server: false // Disable server-side fetching to avoid hydration issues
}); })
// Data processing // Data processing
const champions = computed(() => { const champions = computed(() => {
if (!championsData.value || !Array.isArray(championsData.value)) return []; if (!championsData.value || !Array.isArray(championsData.value)) return []
return championsData.value.slice(1) return championsData.value
.filter((champion: any) => !champion.name.includes("Doom Bot")) .slice(1)
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .filter((champion: any) => !champion.name.includes('Doom Bot'))
}); .sort((a: any, b: any) => a.name.localeCompare(b.name))
})
const lanesMap = computed(() => { const lanesMap = computed(() => {
const map = new Map<string, LaneData[]>(); const map = new Map<string, LaneData[]>()
if (championsLanes.value) { if (championsLanes.value) {
for (const champion of championsLanes.value as ChampionData[]) { 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 // Filter state
const filteredChampions = ref<ChampionSummary[]>([]); const filteredChampions = ref<ChampionSummary[]>([])
const searchText = ref(""); const searchText = ref('')
const searchBar = useTemplateRef("searchBar"); const searchBar = useTemplateRef('searchBar')
// Lane filtering // Lane filtering
function filterToLane(filter: number): string { function filterToLane(filter: number): string {
const laneMap = ["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"]; const laneMap = ['TOP', 'JUNGLE', 'MIDDLE', 'BOTTOM', 'UTILITY']
return laneMap[filter] || ""; return laneMap[filter] || ''
} }
function filterChampionsByLane(laneFilter: number): void { function filterChampionsByLane(laneFilter: number): void {
if (laneFilter === -1) { if (laneFilter === -1) {
filteredChampions.value = [...champions.value]; filteredChampions.value = [...champions.value]
return; return
} }
const laneName = filterToLane(laneFilter); const laneName = filterToLane(laneFilter)
filteredChampions.value = champions.value.filter((champion: any) => { filteredChampions.value = champions.value.filter((champion: any) => {
const championLanes = lanesMap.value.get(champion.alias.toLowerCase()); const championLanes = lanesMap.value.get(champion.alias.toLowerCase())
if (!championLanes) return false; if (!championLanes) return false
return championLanes.some(lane => lane.data === laneName); return championLanes.some(lane => lane.data === laneName)
}); })
} }
// Search functionality // Search functionality
const debouncedSearch = debounce((searchTerm: string) => { const debouncedSearch = debounce((searchTerm: string) => {
if (isEmpty(searchTerm)) { if (isEmpty(searchTerm)) {
filteredChampions.value = [...champions.value]; filteredChampions.value = [...champions.value]
} else { } else {
filteredChampions.value = champions.value.filter((champion: any) => filteredChampions.value = champions.value.filter((champion: any) =>
champion.name.toLowerCase().includes(searchTerm.toLowerCase()) champion.name.toLowerCase().includes(searchTerm.toLowerCase())
); )
} }
}, 300); }, 300)
// Watchers // Watchers
watch(searchBar, (newS, oldS) => { watch(searchBar, (newS, oldS) => {
searchBar.value?.focus(); searchBar.value?.focus()
}); })
watch(searchText, (newTerm) => { watch(searchText, newTerm => {
debouncedSearch(newTerm); debouncedSearch(newTerm)
}); })
// Watch for changes in champions data and update filtered champions // Watch for changes in champions data and update filtered champions
watch(champions, (newChampions) => { watch(
filteredChampions.value = [...newChampions]; champions,
}, { immediate: true }); newChampions => {
filteredChampions.value = [...newChampions]
},
{ immediate: true }
)
// Navigation // Navigation
async function navigateToChampion(championAlias: string): Promise<void> { async function navigateToChampion(championAlias: string): Promise<void> {
try { try {
await navigateTo(`/champion/${championAlias.toLowerCase()}`); await navigateTo(`/champion/${championAlias.toLowerCase()}`)
} catch (error) { } catch (error) {
console.error('Navigation error:', error); console.error('Navigation error:', error)
} }
} }
// Initialize filtered champions // Initialize filtered champions
onMounted(() => { onMounted(() => {
filteredChampions.value = [...champions.value]; filteredChampions.value = [...champions.value]
}); })
// Error handling // Error handling
const hasErrors = computed(() => championsError.value || lanesError.value); const hasErrors = computed(() => championsError.value || lanesError.value)
const isLoading = computed(() => loadingChampions.value || loadingLanes.value); const isLoading = computed(() => loadingChampions.value || loadingLanes.value)
</script> </script>
<template> <template>
<div> <div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="isLoading" class="loading-state"> <div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div> <div class="loading-spinner"/>
<p>Loading champions...</p> <p>Loading champions...</p>
</div> </div>
@@ -122,18 +136,17 @@ const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
<!-- Main content --> <!-- Main content -->
<div v-else> <div v-else>
<div class="search-lanefilter-container"> <div class="search-lanefilter-container">
<LaneFilter <LaneFilter id="cs-lanefilter" @filter-change="filterChampionsByLane" />
id="cs-lanefilter"
@filter-change="filterChampionsByLane"
/>
<input <input
@keyup.enter="() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)"
v-model="searchText"
ref="searchBar" ref="searchBar"
v-model="searchText"
class="search-bar" class="search-bar"
type="text" type="text"
placeholder="Search a champion" placeholder="Search a champion"
/> @keyup.enter="
() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)
"
>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
@@ -146,7 +159,7 @@ const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
v-for="champion in filteredChampions" v-for="champion in filteredChampions"
:key="champion.id" :key="champion.id"
:to="'/champion/' + champion.alias.toLowerCase()" :to="'/champion/' + champion.alias.toLowerCase()"
style="width: fit-content; height: fit-content;" style="width: fit-content; height: fit-content"
> >
<div class="cs-champion-img-container"> <div class="cs-champion-img-container">
<NuxtImg <NuxtImg
@@ -164,7 +177,9 @@ const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
<style> <style>
/* Loading and error states */ /* Loading and error states */
.loading-state, .error-state, .empty-state { .loading-state,
.error-state,
.empty-state {
text-align: center; text-align: center;
padding: 40px 20px; padding: 40px 20px;
color: var(--color-on-surface); color: var(--color-on-surface);
@@ -188,8 +203,12 @@ const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.loading-state p { .loading-state p {
@@ -198,7 +217,8 @@ const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
100% {
opacity: 0.6; opacity: 0.6;
} }
50% { 50% {
@@ -257,7 +277,9 @@ const isLoading = computed(() => loadingChampions.value || loadingLanes.value);
margin-bottom: 20px; margin-bottom: 20px;
} }
.cs-champion-img-container { .cs-champion-img-container {
overflow: hidden; width: 120px; height: 120px; overflow: hidden;
width: 120px;
height: 120px;
border: 1px solid var(--color-surface); border: 1px solid var(--color-surface);
} }
.cs-champion-img-container:hover { .cs-champion-img-container:hover {

View File

@@ -1,26 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
championId: number, championId: number
winrate: number, winrate: number
pickrate: number, pickrate: number
gameCount: number gameCount: number
}>() }>()
const winrate = ref((props.winrate * 100).toFixed(2)) 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)) 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 championName = championData.value.name
const championDescription = championData.value.title const championDescription = championData.value.title
</script> </script>
<template> <template>
<div style="display: flex; width: fit-content;"> <div style="display: flex; width: fit-content">
<div class="champion-title-img-container"> <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'"/> <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>
<div id="ct-info-container"> <div id="ct-info-container">

View File

@@ -1,11 +1,18 @@
<script setup lang="ts"> <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<{ 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) const laneFilter = ref(-1)
function selectLaneFilter(index: number) { function selectLaneFilter(index: number) {
@@ -15,9 +22,9 @@ function selectLaneFilter(index: number) {
// This is a deselection. // This is a deselection.
if (laneFilter.value == index) { if (laneFilter.value == index) {
laneFilter.value = -1; laneFilter.value = -1
emit('filterChange', laneFilter.value) emit('filterChange', laneFilter.value)
return; return
} }
} }
@@ -28,24 +35,25 @@ function selectLaneFilter(index: number) {
} }
function handleMouseOut(laneImg: Ref<string>, index: number) { function handleMouseOut(laneImg: Ref<string>, index: number) {
if(laneImg.value == LANE_IMAGES_HOVER[index]) if (laneImg.value == LANE_IMAGES_HOVER[index]) laneImg.value = LANE_IMAGES[index]
laneImg.value = LANE_IMAGES[index]
} }
function handleHover(laneImg: Ref<string>, index: number) { function handleHover(laneImg: Ref<string>, index: number) {
if(laneImg.value == LANE_IMAGES[index]) if (laneImg.value == LANE_IMAGES[index]) laneImg.value = LANE_IMAGES_HOVER[index]
laneImg.value = LANE_IMAGES_HOVER[index]
} }
</script> </script>
<template> <template>
<div style="width: fit-content;"> <div style="width: fit-content">
<NuxtImg v-for="(laneImg, index) in laneImgs" <NuxtImg
v-for="(laneImg, index) in laneImgs"
format="webp" format="webp"
:alt="POSITIONS_STR[index]" :alt="POSITIONS_STR[index]"
class="lane-img" :src="laneImg.value" class="lane-img"
:src="laneImg.value"
@mouseout="handleMouseOut(laneImg, index)" @mouseout="handleMouseOut(laneImg, index)"
@mouseover="handleHover(laneImg, index)" @mouseover="handleHover(laneImg, index)"
@click="selectLaneFilter(index)"/> @click="selectLaneFilter(index)"
/>
</div> </div>
</template> </template>

View File

@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
imgWidth?: String, imgWidth?: string
fontSize?: String fontSize?: string
}>() }>()
</script> </script>
<template> <template>
<div style="width: fit-content; max-width: 100%; overflow: hidden;"> <div style="width: fit-content; max-width: 100%; overflow: hidden">
<NuxtLink style="display: flex; width: fit-content; text-decoration: none;" to="/"> <NuxtLink style="display: flex; width: fit-content; text-decoration: none" to="/">
<NuxtImg id="logo-img" alt="BuildPath" <NuxtImg
id="logo-img"
alt="BuildPath"
format="webp" format="webp"
:width="imgWidth == null ? '120' : Number(imgWidth)" :width="imgWidth == null ? '120' : Number(imgWidth)"
src="/buildpath-high-resolution-logo-transparent.png" /> src="/buildpath-high-resolution-logo-transparent.png"
<h1 :style="'font-size: ' + (fontSize == null ? '5.0rem' : fontSize) + ';'" id="logo-text">BuildPath</h1> />
<h1 id="logo-text" :style="'font-size: ' + (fontSize == null ? '5.0rem' : fontSize) + ';'">
BuildPath
</h1>
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>

View File

@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
title: string, title: string
id: number, id: number
winrate: number, winrate: number
pickrate: number, pickrate: number
gameCount: number gameCount: number
}>() }>()
</script> </script>
<template> <template>
@@ -23,12 +22,22 @@ defineProps<{
</div> </div>
<div class="text-[200px]"> <div class="text-[200px]">
<!-- Champion image --> <!-- Champion image -->
<div class="my-auto ml-10" <div
style="overflow: hidden; width: 220px; height: 220px; border: 1px solid #B7B8E1;"> class="my-auto ml-10"
<NuxtImg width="216px" height="216px" style="overflow: hidden; width: 220px; height: 220px; border: 1px solid #b7b8e1"
>
<NuxtImg
width="216px"
height="216px"
class="object-cover" class="object-cover"
style="transform: translate(4px, 4px) scale(1.2, 1.2); width: 216px; height: 216px;" 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'" /> :src="
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' +
id +
'.png'
"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -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> --> <!-- <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="pl-2">
<div class="text-[#B7B8E1]">{{ (winrate * 100).toFixed(2) }}%</div> <div class="text-[#B7B8E1]">{{ (winrate * 100).toFixed(2) }}%</div>
<div class="text-lg text-[#B7B8E1]"> <div class="text-lg text-[#B7B8E1]">Winrate</div>
Winrate
</div>
</div> </div>
</div> </div>
<div class="flex flex-row pr-10"> <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> --> <!-- <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="pl-2">
<div class="text-[#B7B8E1]">{{ (pickrate * 100).toFixed(2) }}%</div> <div class="text-[#B7B8E1]">{{ (pickrate * 100).toFixed(2) }}%</div>
<div class="text-lg text-[#B7B8E1]"> <div class="text-lg text-[#B7B8E1]">Pickrate</div>
Pickrate
</div>
</div> </div>
</div> </div>
<div class="flex flex-row pr-10"> <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> --> <!-- <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="pl-2">
<div class="text-[#B7B8E1]">{{ gameCount }}</div> <div class="text-[#B7B8E1]">{{ gameCount }}</div>
<div class="text-lg text-[#B7B8E1]"> <div class="text-lg text-[#B7B8E1]">Games</div>
Games
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -86,4 +89,3 @@ defineProps<{
<div class="absolute bottom-0 w-full h-8 bg-[#B7B8E1]" /> <div class="absolute bottom-0 w-full h-8 bg-[#B7B8E1]" />
</div> </div>
</template> </template>

View File

@@ -1,17 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ defineProps<{
title: string, title: string
bootsFirst?: number, bootsFirst?: number
sizePerc?: number sizePerc?: number
}>() }>()
</script> </script>
<template> <template>
<div :style="(sizePerc != undefined && sizePerc != null) ? 'max-height: ' + (sizePerc * 600) + 'px;' : ''" class="item-box"> <div
<div style="display:flex; flex-direction: column; justify-content: center; align-items: center;"> :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> <h2 class="item-box-title">{{ title }}</h2>
<h5 v-if="bootsFirst != undefined && bootsFirst != null" <h5 v-if="bootsFirst != undefined && bootsFirst != null" style="margin: auto">
style="margin: auto;">({{ (bootsFirst * 100).toFixed(2) }}%)</h5> ({{ (bootsFirst * 100).toFixed(2) }}%)
</h5>
</div> </div>
<slot /> <slot />
</div> </div>

View File

@@ -1,32 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import svgdomarrows from 'svg-dom-arrows'
defineProps<{ defineProps<{
tree: ItemTree, tree: ItemTree
parentCount?: number parentCount?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
mount: [end: Element], mount: [end: Element]
refresh: [] 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()) const itemMap = reactive(new Map())
for(let item of items.value) { for (const item of items.value) {
itemMap.set(item.id, item) itemMap.set(item.id, item)
} }
import svgdomarrows from 'svg-dom-arrows'; const start: Ref<Element | null> = useTemplateRef('start')
const start : Ref<Element | null> = useTemplateRef("start")
const arrows: Array<svgdomarrows.LinePath> = [] const arrows: Array<svgdomarrows.LinePath> = []
onMounted(() => { onMounted(() => {
refreshArrows() refreshArrows()
emit('mount', start.value!!) emit('mount', start.value!)
}) })
onBeforeUpdate(() => { onBeforeUpdate(() => {
for(let arrow of arrows) { for (const arrow of arrows) {
arrow.release() arrow.release()
} }
arrows.splice(0, arrows.length) arrows.splice(0, arrows.length)
@@ -34,18 +36,18 @@ onBeforeUpdate(() => {
onUpdated(() => { onUpdated(() => {
refreshArrows() refreshArrows()
emit('mount', start.value!!) emit('mount', start.value!)
}) })
onUnmounted(() => { onUnmounted(() => {
for(let arrow of arrows) { for (const arrow of arrows) {
arrow.release() arrow.release()
} }
}) })
function drawArrow(start: Element, end: Element) { function drawArrow(start: Element, end: Element) {
// console.log("drawArrow(", start, ", ", end, ")") // console.log("drawArrow(", start, ", ", end, ")")
if(start == null || end == null) return; if (start == null || end == null) return
const arrow = new svgdomarrows.LinePath({ const arrow = new svgdomarrows.LinePath({
start: { start: {
@@ -69,21 +71,21 @@ function drawArrow(start : Element, end : Element) {
} }
function refreshArrows() { function refreshArrows() {
for(let arrow of arrows) { for (const arrow of arrows) {
arrow.redraw() arrow.redraw()
} }
} }
// Redraw arrows on window resize // Redraw arrows on window resize
addEventListener('resize', (_) => { addEventListener('resize', _ => {
refreshArrows() refreshArrows()
}) })
addEventListener("scroll", (_) => { addEventListener('scroll', _ => {
refreshArrows() refreshArrows()
}) })
function handleSubtreeMount(end: Element) { function handleSubtreeMount(end: Element) {
drawArrow(start.value!!, end) drawArrow(start.value!, end)
refreshArrows() refreshArrows()
emit('refresh') emit('refresh')
} }
@@ -94,18 +96,32 @@ function handleRefresh() {
</script> </script>
<template> <template>
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center">
<div
<div v-if="tree.data != undefined && tree.data != null" style="width: fit-content; height: fit-content;"> v-if="tree.data != undefined && tree.data != null"
<img ref="start" class="item-img" width="64" height="64" style="width: fit-content; height: fit-content"
>
<img
ref="start"
class="item-img"
width="64"
height="64"
:alt="tree.data.toString()" :alt="tree.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(tree.data).iconPath)" /> :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> >
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%
</h3>
</div> </div>
<div style="margin-left: 30px;"> <div style="margin-left: 30px">
<div style="width: fit-content; height: fit-content;" v-for="child in tree.children"> <div v-for="child in tree.children" style="width: fit-content; height: fit-content">
<ItemTree @refresh="handleRefresh" @mount="(end) => handleSubtreeMount(end)" :tree="child" :parent-count="tree.count" /> <ItemTree
:tree="child"
:parent-count="tree.count"
@refresh="handleRefresh"
@mount="end => handleSubtreeMount(end)"
/>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -7,16 +7,19 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
stateChange: [state: string, lane: number] stateChange: [state: string, lane: number]
}>() }>()
</script> </script>
<template> <template>
<LazyNavSideBar :champion-name="championName" <LazyNavSideBar
:champion-name="championName"
:champion-lanes="championLanes" :champion-lanes="championLanes"
:tierlist-list="tierlistList" :tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"/> @state-change="(s, l) => emit('stateChange', s, l)"
<LazyNavBottomBar :champion-name="championName" />
<LazyNavBottomBar
:champion-name="championName"
:champion-lanes="championLanes" :champion-lanes="championLanes"
:tierlist-list="tierlistList" :tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"/> @state-change="(s, l) => emit('stateChange', s, l)"
/>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'; import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
defineProps<{ defineProps<{
championName?: string championName?: string
@@ -10,18 +10,18 @@ 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) const laneState = ref(0)
function handleStateChange(newState: string, newLane: number) { function handleStateChange(newState: string, newLane: number) {
state.value = newState; state.value = newState
laneState.value = newLane; laneState.value = newLane
emit('stateChange', newState, newLane) emit('stateChange', newState, newLane)
} }
const route = useRoute() const route = useRoute()
const selected = ref(""); const selected = ref('')
if(route.path.startsWith("/tierlist/")) { if (route.path.startsWith('/tierlist/')) {
const lane = route.params.lane as string const lane = route.params.lane as string
selected.value = lane selected.value = lane
} }
@@ -29,40 +29,74 @@ if(route.path.startsWith("/tierlist/")) {
<template> <template>
<div class="navbar-container"> <div class="navbar-container">
<NuxtLink <NuxtLink
style="display: flex; width: fit-content; text-decoration: none; align-items: center; margin-left: 10px;" style="
display: flex;
width: fit-content;
text-decoration: none;
align-items: center;
margin-left: 10px;
"
to="/" to="/"
prefetch prefetch
> >
<NuxtImg format="webp" id="navbar-logo-img" <NuxtImg
src="/buildpath-high-resolution-logo-transparent.png" /> id="navbar-logo-img"
format="webp"
src="/buildpath-high-resolution-logo-transparent.png"
/>
</NuxtLink> </NuxtLink>
<div v-for="(lane, i) in championLanes" style="display: flex; align-items: center; margin-left: 20px;"> <div
<NuxtImg format="webp" width="40" height="40" v-for="(lane, i) in championLanes"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]" /> style="display: flex; align-items: center; margin-left: 20px"
>
<NuxtImg
format="webp"
width="40"
height="40"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
/>
<div> <div>
<h2 :class="'navbar-link ' + (state == 'runes' && laneState == i ? 'navbar-link-selected' : '')" <h2
@click="handleStateChange('runes', i)" > :class="
'navbar-link ' + (state == 'runes' && laneState == i ? 'navbar-link-selected' : '')
"
@click="handleStateChange('runes', i)"
>
Runes Runes
</h2> </h2>
<h2 :class="'navbar-link ' + (state == 'items' && laneState == i ? 'navbar-link-selected' : '')" <h2
@click="handleStateChange('items', i)" > :class="
'navbar-link ' + (state == 'items' && laneState == i ? 'navbar-link-selected' : '')
"
@click="handleStateChange('items', i)"
>
Items Items
</h2> </h2>
</div> </div>
</div> </div>
<div v-if="tierlistList == true" style="padding-left: 20px;"> <div v-if="tierlistList == true" style="padding-left: 20px">
<h2 style="padding-left: 0px; font-size: 1.4rem; margin-top: 15px;">Tierlist</h2> <h2 style="padding-left: 0px; font-size: 1.4rem; margin-top: 15px">Tierlist</h2>
<div style="display: flex;"> <div style="display: flex">
<NuxtLink style="margin-top: 5px; margin-bottom: 5px;" v-for="(pos, i) in POSITIONS" :to="'/tierlist/' + pos"> <NuxtLink
<div :class="selected == pos ? 'navbar-link-selected' : ''" v-for="(pos, i) in POSITIONS"
class="navbar-link" style="display: flex; align-items: center;"> style="margin-top: 5px; margin-bottom: 5px"
<NuxtImg format="webp" :to="'/tierlist/' + pos"
width="30" height="30" >
:src="LANE_IMAGES[i]" :alt="POSITIONS_STR[i]" /> <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> </div>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -78,7 +112,7 @@ if(route.path.startsWith("/tierlist/")) {
bottom: 0; bottom: 0;
z-index: 10; z-index: 10;
background-color: #2B2826; background-color: #2b2826;
width: 100%; width: 100%;
height: 100px; height: 100px;

View File

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

View File

@@ -5,55 +5,92 @@ const props = defineProps<{
selectionIds: Array<number> selectionIds: Array<number>
}>() }>()
const primaryStyle : 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:[]}) 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()) const perks = reactive(new Map())
for(let perk of perks_data.value) { for (const perk of perks_data.value) {
perks.set(perk.id, perk) perks.set(perk.id, perk)
} }
let { data: stylesData } : PerkStylesResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json") const { data: stylesData }: PerkStylesResponse = await useFetch(
watch(() => props.primaryStyleId, async (newP, oldP) => {refreshStyles()}) CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
watch(() => props.secondaryStyleId, async (newP, oldP) => {refreshStyles()}) )
watch(
() => props.primaryStyleId,
async (newP, oldP) => {
refreshStyles()
}
)
watch(
() => props.secondaryStyleId,
async (newP, oldP) => {
refreshStyles()
}
)
function refreshStyles() { function refreshStyles() {
for(let style of stylesData.value.styles) { for (const style of stylesData.value.styles) {
if(style.id == (props.primaryStyleId)) { if (style.id == props.primaryStyleId) {
primaryStyle.value = style primaryStyle.value = style
} }
if(style.id == (props.secondaryStyleId)) { if (style.id == props.secondaryStyleId) {
secondaryStyle.value = style secondaryStyle.value = style
} }
} }
} }
refreshStyles() refreshStyles()
</script> </script>
<template> <template>
<div style="display: flex;"> <div style="display: flex">
<div class="rune-holder"> <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">
<div class="rune-slot" v-for="slot in primaryStyle.slots.slice(0, 1)"> <NuxtImg
<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)"/> class="rune-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(primaryStyle.iconPath)"
/>
</div> </div>
<div class="rune-slot" v-for="slot in primaryStyle.slots.slice(1, 4)"> <div v-for="slot in primaryStyle.slots.slice(0, 1)" class="rune-slot">
<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)"/> <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> </div>
<div class="rune-spacer-bar"></div> <div class="rune-spacer-bar"/>
<div class="rune-holder" style="align-content: end"> <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">
<div class="rune-slot" v-for="slot in secondaryStyle.slots.slice(1, 4)"> <img style="margin: auto" :src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)" >
<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 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>
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)"> <!-- <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)"/> <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>
</div> </div>
</template> </template>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
runes: Array<{count: number, runes: Array<{
primaryStyle: number, count: number
secondaryStyle: number, primaryStyle: number
selections: Array<number>, secondaryStyle: number
pickrate: number}> selections: Array<number>
pickrate: number
}>
}>() }>()
const currentlySelectedPage = ref(0) const currentlySelectedPage = ref(0)
@@ -12,28 +14,35 @@ const primaryStyles : Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length)) const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const keystoneIds: Ref<Array<number>> = 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()) const perks = reactive(new Map())
for(let perk of perks_data.value) { for (const perk of perks_data.value) {
perks.set(perk.id, perk) perks.set(perk.id, perk)
} }
let { data: stylesData } : PerkStylesResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json") const { data: stylesData }: PerkStylesResponse = await useFetch(
watch(() => props.runes, (newRunes, oldRunes) => { CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
)
watch(
() => props.runes,
(newRunes, oldRunes) => {
currentlySelectedPage.value = 0 currentlySelectedPage.value = 0
primaryStyles.value = Array(props.runes.length) primaryStyles.value = Array(props.runes.length)
secondaryStyles.value = Array(props.runes.length) secondaryStyles.value = Array(props.runes.length)
keystoneIds.value = Array(props.runes.length) keystoneIds.value = Array(props.runes.length)
refreshStylesKeystones() refreshStylesKeystones()
}) }
)
function refreshStylesKeystones() { function refreshStylesKeystones() {
for(let style of stylesData.value.styles) { for (const style of stylesData.value.styles) {
for(let rune of props.runes) { for (const rune of props.runes) {
if (style.id == rune.primaryStyle) { if (style.id == rune.primaryStyle) {
primaryStyles.value[props.runes.indexOf(rune)] = style primaryStyles.value[props.runes.indexOf(rune)] = style
for(let perk of style.slots[0].perks) { for (const perk of style.slots[0].perks) {
if (rune.selections.includes(perk)) { if (rune.selections.includes(perk)) {
keystoneIds.value[props.runes.indexOf(rune)] = perk keystoneIds.value[props.runes.indexOf(rune)] = perk
} }
@@ -51,26 +60,44 @@ refreshStylesKeystones()
function runeSelect(index: number) { function runeSelect(index: number) {
currentlySelectedPage.value = index currentlySelectedPage.value = index
} }
</script> </script>
<template> <template>
<div style="width: fit-content;"> <div style="width: fit-content">
<RunePage v-if="runes[currentlySelectedPage] != undefined && runes[currentlySelectedPage] != null" <RunePage
style="margin:auto; width: fit-content;" v-if="runes[currentlySelectedPage] != undefined && runes[currentlySelectedPage] != null"
:primaryStyleId="runes[currentlySelectedPage].primaryStyle" style="margin: auto; width: fit-content"
:secondaryStyleId="runes[currentlySelectedPage].secondaryStyle" :primary-style-id="runes[currentlySelectedPage].primaryStyle"
:selectionIds="runes[currentlySelectedPage].selections" /> :secondary-style-id="runes[currentlySelectedPage].secondaryStyle"
<div style="display: flex; margin-top: 20px; justify-content: center;"> :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 v-for="(_, i) in runes" @click="runeSelect(i)">
<div :class="'rune-selector-entry ' + (i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')"> <div
:class="
'rune-selector-entry ' +
(i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')
"
>
<div class="rs-styles-container"> <div class="rs-styles-container">
<NuxtImg class="rs-style-img" v-if="primaryStyles[i] != null && primaryStyles[i] != undefined" <NuxtImg
style="margin: auto;" :src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)" /> v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
<NuxtImg class="rs-style-img" v-if="keystoneIds[i] != null && keystoneIds[i] != undefined" class="rs-style-img"
width="34" :src="CDRAGON_BASE + ( mapPath(perks.get(keystoneIds[i]).iconPath))"/> style="margin: auto"
<NuxtImg class="rs-style-img" v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined" :src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)"
style="margin: auto;" :src="CDRAGON_BASE + mapPath(secondaryStyles[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>
</div> </div>
<h3 class="rs-pickrate">{{ (runes[i].pickrate * 100).toFixed(2) }}% pick.</h3> <h3 class="rs-pickrate">{{ (runes[i].pickrate * 100).toFixed(2) }}% pick.</h3>

View File

@@ -1,12 +1,22 @@
<script lang="ts" setup> <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' import { Bar } from 'vue-chartjs'
// Register // Register
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale) ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
const props = defineProps<{ 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 labels: Array<string> = []
@@ -14,13 +24,13 @@ const pickrates: Array<number> = []
const images: Array<string> = [] const images: Array<string> = []
const backgroundColors: Array<string> = [] const backgroundColors: Array<string> = []
const CHAMPION_CUT_THRESHOLD = 32 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 count = 0
let colorIndex = 0 let colorIndex = 0
for(let tier of props.data) { for (const tier of props.data) {
for(let {champion: champion, lane: lane} of tier.data) { for (const { champion: champion, lane: lane } of tier.data) {
if(count > CHAMPION_CUT_THRESHOLD) break; if (count > CHAMPION_CUT_THRESHOLD) break
labels.push(champion.name) labels.push(champion.name)
pickrates.push(lane.pickrate * 100) pickrates.push(lane.pickrate * 100)
@@ -32,7 +42,6 @@ for(let tier of props.data) {
colorIndex++ colorIndex++
} }
const chartData = ref({ const chartData = ref({
labels: labels, labels: labels,
datasets: [ datasets: [
@@ -40,9 +49,9 @@ const chartData = ref({
label: 'Pickrate', label: 'Pickrate',
backgroundColor: backgroundColors, backgroundColor: backgroundColors,
barPercentage: 1.0, barPercentage: 1.0,
data: pickrates, data: pickrates
}, }
], ]
}) })
const chartOptions = ref({ const chartOptions = ref({
responsive: true, responsive: true,
@@ -50,7 +59,7 @@ const chartOptions = ref({
scales: { scales: {
x: { x: {
ticks: { ticks: {
callback: (() => "") callback: () => ''
} }
} }
}, },
@@ -60,19 +69,21 @@ const chartOptions = ref({
} }
} }
}) })
const chartPlugins = [{ const chartPlugins = [
id: "image-draw", {
afterDraw: ((chart: any) => { id: 'image-draw',
afterDraw: (chart: any) => {
const ctx: CanvasRenderingContext2D = chart.ctx const ctx: CanvasRenderingContext2D = chart.ctx
var xAxis = chart.scales.x; const xAxis = chart.scales.x
xAxis.ticks.forEach((value: any, index: number) => { xAxis.ticks.forEach((value: any, index: number) => {
var x = xAxis.getPixelForTick(index) const x = xAxis.getPixelForTick(index)
var image = new Image() const image = new Image()
image.src = images[index] image.src = images[index]
ctx.drawImage(image, x - 14, xAxis.bottom - 28, 28, 28) ctx.drawImage(image, x - 14, xAxis.bottom - 28, 28, 28)
}) })
}) }
}] }
]
</script> </script>
<template> <template>

View File

@@ -1,17 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
title: string title: string
tier: Array<{champion: Champion, lane: LaneData}> tier: Array<{ champion: Champion; lane: LaneData }>
}>() }>()
</script> </script>
<template> <template>
<div style="display: flex;"> <div style="display: flex">
<h2 class="tierlist-tier-title">{{ title }}</h2> <h2 class="tierlist-tier-title">{{ title }}</h2>
<div class="tierlist-tier-container"> <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"> <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> </div>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -43,7 +50,9 @@ defineProps<{
} }
.champion-img-container { .champion-img-container {
overflow: hidden; width: 120px; height: 120px; overflow: hidden;
width: 120px;
height: 120px;
border: 1px solid var(--color-surface); border: 1px solid var(--color-surface);
} }
.champion-img-container:hover { .champion-img-container:hover {

View File

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

@@ -13,35 +13,31 @@ export default defineNuxtConfig({
url: 'https://buildpath.win', url: 'https://buildpath.win',
name: 'BuildPath', name: 'BuildPath',
description: 'BuildPath: a tool for League of Legends champions runes and build paths.', 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: { sitemap: {
sources: [ sources: ['/api/routemap']
'/api/routemap'
]
}, },
app: { app: {
head: { head: {
htmlAttrs: { htmlAttrs: {
lang: 'en', lang: 'en'
}, },
link: [ link: [
{ rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' }, { rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'shortcut icon', href: '/favicon.ico' }, { rel: 'shortcut icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', sizes: "180x180", href: '/apple-touch-icon.png' }, { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/site.webmanifest' }, { rel: 'manifest', href: '/site.webmanifest' }
],
meta: [
{name: "apple-mobile-web-app-title", content:"BuildPath"},
], ],
meta: [{ name: 'apple-mobile-web-app-title', content: 'BuildPath' }],
charset: 'utf-8', charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1' 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: { umami: {
id: '98ef53ef-5fe1-4e29-a35e-56dc1283c212', id: '98ef53ef-5fe1-4e29-a35e-56dc1283c212',
@@ -49,12 +45,12 @@ export default defineNuxtConfig({
autoTrack: true, autoTrack: true,
domains: ['buildpath.win'], domains: ['buildpath.win'],
ignoreLocalhost: true, ignoreLocalhost: true,
enabled: true, enabled: true
}, },
fonts: { fonts: {
defaults: { 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", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@nuxt/eslint": "^1.12.1",
"@nuxt/fonts": "^0.11.3", "@nuxt/fonts": "^0.11.3",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxtjs/seo": "^3.0.3", "@nuxtjs/seo": "^3.0.3",
@@ -24,7 +29,15 @@
"vue-router": "latest" "vue-router": "latest"
}, },
"devDependencies": { "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", "@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", "typescript": "^5.7.2",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.1.10"
} }

View File

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

View File

@@ -2,14 +2,14 @@
const route = useRoute() const route = useRoute()
const championAlias = route.params.alias as string 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 const championId = championData.value.id
// Prefetch home page for faster navigation // Prefetch home page for faster navigation
useHead({ useHead({
link: [ link: [{ rel: 'prefetch', href: '/' }]
{ rel: 'prefetch', href: '/' }
]
}) })
defineOgImageComponent('Champion', { defineOgImageComponent('Champion', {
@@ -17,7 +17,7 @@ defineOgImageComponent('Champion', {
id: championId, id: championId,
winrate: championData.value.winrate, winrate: championData.value.winrate,
pickrate: championData.value.pickrate, pickrate: championData.value.pickrate,
gameCount: championData.value.gameCount, gameCount: championData.value.gameCount
}) })
useSeoMeta({ useSeoMeta({
title: championData.value.name, title: championData.value.name,
@@ -25,7 +25,7 @@ useSeoMeta({
}) })
const laneState = ref(0) const laneState = ref(0)
const state = ref("runes") const state = ref('runes')
const lane = ref(championData.value.lanes[laneState.value]) const lane = ref(championData.value.lanes[laneState.value])
function updateState(newState: string, newLane: number) { function updateState(newState: string, newLane: number) {
state.value = newState state.value = newState
@@ -40,30 +40,43 @@ function updateState(newState : string, newLane : number) {
</Head> </Head>
<div id="alias-content-wrapper"> <div id="alias-content-wrapper">
<NavBar
<NavBar :champion-name="championData.name" :champion-name="championData.name"
:champion-lanes="championData.lanes" :champion-lanes="championData.lanes"
@state-change="updateState"/> @state-change="updateState"
/>
<div id="champion-content"> <div id="champion-content">
<ChampionTitle id="champion-title" v-if="championData.gameCount > 0" <ChampionTitle
:champion-id="championId" :winrate="lane.winrate" v-if="championData.gameCount > 0"
:pickrate="lane.pickrate" :game-count="lane.count" /> id="champion-title"
<RuneSelector v-if="state == 'runes' && championData.gameCount > 0" :champion-id="championId"
style="margin: auto; margin-top: 40px;" :winrate="lane.winrate"
:runes="lane.runes!!" /> :pickrate="lane.pickrate"
<ItemViewer v-if="state == 'items' && championData.gameCount > 0" :game-count="lane.count"
style="margin:auto; margin-top: 40px;" />
:builds="lane.builds!!" /> <RuneSelector
<ItemTree v-if="state == 'alternatives' && championData.gameCount > 0" v-if="state == 'runes' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px; width: fit-content;" style="margin: auto; margin-top: 40px"
:tree="lane.builds!!.tree" /> :runes="lane.runes!!"
<h2 v-if="championData.gameCount == 0" />
style="margin: auto; margin-top: 20px; width: fit-content;"> <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 :( Sorry, there is no data for this champion :(
</h2> </h2>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { POSITIONS, LANE_IMAGES, POSITIONS_STR } from '~/utils/cdragon'; import { POSITIONS, LANE_IMAGES, POSITIONS_STR } from '~/utils/cdragon'
</script> </script>
<template> <template>
@@ -12,7 +11,6 @@ import { POSITIONS, LANE_IMAGES, POSITIONS_STR } from '~/utils/cdragon';
<NavBar :tierlist-list="true" /> <NavBar :tierlist-list="true" />
<ChampionSelector class="index-champion-selector" /> <ChampionSelector class="index-champion-selector" />
</div> </div>
</template> </template>
@@ -59,5 +57,4 @@ import { POSITIONS, LANE_IMAGES, POSITIONS_STR } from '~/utils/cdragon';
display: none; display: none;
} }
} }
</style> </style>

View File

@@ -1,31 +1,38 @@
<script setup lang="ts"> <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 route = useRoute()
const lane = route.params.lane as string 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 { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
await useFetch('/api/champions')
const infoMap: Map<string, ChampionData> = new Map() const infoMap: Map<string, ChampionData> = new Map()
for(let champion of championsLanes.value) { for (const champion of championsLanes.value) {
infoMap.set(champion.alias, champion) infoMap.set(champion.alias, champion)
} }
const champions = championsData.value.slice(1).filter((champion) => { const champions = championsData.value.slice(1).filter(champion => {
const championData: ChampionData | undefined = infoMap.get(champion.alias.toLowerCase()) const championData: ChampionData | undefined = infoMap.get(champion.alias.toLowerCase())
if(championData == undefined) return false; if (championData == undefined) return false
const lanes = championData.lanes const lanes = championData.lanes
return lanes.reduce((acc : boolean, current : {data:string, count:number}) => return lanes.reduce(
acc || (current.data.toLowerCase() == lane.toLowerCase()), false) (acc: boolean, current: { data: string; count: number }) =>
acc || current.data.toLowerCase() == lane.toLowerCase(),
false
)
}) })
const allChampions = champions.map((x) => { const allChampions = champions
const championData : ChampionData = infoMap.get(x.alias.toLowerCase())!! .map(x => {
const championData: ChampionData = infoMap.get(x.alias.toLowerCase())!
let currentLane = championData.lanes[0] let currentLane = championData.lanes[0]
for(let championLane of championData.lanes) { for (const championLane of championData.lanes) {
if (championLane.data.toLowerCase() == lane.toLowerCase()) { if (championLane.data.toLowerCase() == lane.toLowerCase()) {
currentLane = championLane currentLane = championLane
break break
@@ -33,27 +40,33 @@ const allChampions = champions.map((x) => {
} }
return { lane: currentLane, champion: x } return { lane: currentLane, champion: x }
}).sort((a, b) => b.lane.pickrate - a.lane.pickrate) })
.sort((a, b) => b.lane.pickrate - a.lane.pickrate)
const p_min = Math.min(...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)) 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) allChampions.sort((a, b) => b.lane.pickrate - a.lane.pickrate)
function tierFromScaledPickrate(min: number, max: number) { function tierFromScaledPickrate(min: number, max: number) {
return (allChampions as Array<{lane: LaneData, champion: Champion, scaledPickrate: number}>) return (
.filter(({scaledPickrate: scaledPickrate}) => { allChampions as Array<{ lane: LaneData; champion: Champion; scaledPickrate: number }>
).filter(({ scaledPickrate: scaledPickrate }) => {
return scaledPickrate > min && scaledPickrate <= max return scaledPickrate > min && scaledPickrate <= max
}) })
} }
const tiers: Array<{title:string, data: Array<{lane: LaneData, champion: Champion}>}> = [] const tiers: Array<{ title: string; data: Array<{ lane: LaneData; champion: Champion }> }> = []
tiers.push({title: "S", data: tierFromScaledPickrate(0.9, 1)}) tiers.push({ title: 'S', data: tierFromScaledPickrate(0.9, 1) })
tiers.push({title: "A", data: tierFromScaledPickrate(0.7, 0.9)}) tiers.push({ title: 'A', data: tierFromScaledPickrate(0.7, 0.9) })
tiers.push({title: "B", data: tierFromScaledPickrate(0.5, 0.7)}) tiers.push({ title: 'B', data: tierFromScaledPickrate(0.5, 0.7) })
tiers.push({title: "C", data: tierFromScaledPickrate(0.3, 0.5)}) tiers.push({ title: 'C', data: tierFromScaledPickrate(0.3, 0.5) })
tiers.push({title: "D", data: tierFromScaledPickrate(0.1, 0.3)}) tiers.push({ title: 'D', data: tierFromScaledPickrate(0.1, 0.3) })
tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)}) tiers.push({ title: 'F', data: tierFromScaledPickrate(0, 0.1) })
</script> </script>
<template> <template>
@@ -61,26 +74,37 @@ tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)})
<Title>Tierlist for {{ POSITIONS_STR[lanePositionToIndex(lane)] }}</Title> <Title>Tierlist for {{ POSITIONS_STR[lanePositionToIndex(lane)] }}</Title>
</Head> </Head>
<div style="display: flex; min-height: 100vh; align-items: stretch; width: 100%;"> <div style="display: flex; min-height: 100vh; align-items: stretch; width: 100%">
<NavBar :tierlist-list="true" /> <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
<div style="margin-left: 0px; margin-top: 20px; display: flex; margin-bottom: 30px; align-items: center"> style="
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300;">Tierlist for</h1> margin-left: 0px;
<NuxtImg format="webp" style="margin-left: 10px;" margin-top: 20px;
width="50" height="50" display: flex;
:src="LANE_IMAGES[lanePositionToIndex(lane)]" /> margin-bottom: 30px;
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300;">{{ POSITIONS_STR[lanePositionToIndex(lane)] }}</h1> 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>
<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" /> <TierlistChart id="chart" :data="tiers" />
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
@@ -88,7 +112,7 @@ tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)})
margin-left: 100px; margin-left: 100px;
margin-right: 100px; margin-right: 100px;
margin-bottom: 100px; margin-bottom: 100px;
margin-top: 40px margin-top: 40px;
} }
@media only screen and (max-width: 450px) { @media only screen and (max-width: 450px) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,11 @@ import { MongoClient } from 'mongodb'
async function connectToDatabase() { async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version // 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}` 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 != "") { if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI uri = process.env.MONGO_URI
} }
const client = new MongoClient(uri) const client = new MongoClient(uri)
@@ -12,10 +16,10 @@ async function connectToDatabase() {
} }
async function fetchLatestPatch(client: MongoClient) { async function fetchLatestPatch(client: MongoClient) {
const database = client.db("patches"); const database = client.db('patches')
const patches = database.collection("patches"); const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next() const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return latestPatch!!.patch as string return latestPatch!.patch as string
} }
export { connectToDatabase, fetchLatestPatch } export { connectToDatabase, fetchLatestPatch }

View File

@@ -3,72 +3,71 @@ declare global {
* Represents an item in the build tree * Represents an item in the build tree
*/ */
interface ItemTree { interface ItemTree {
count: number; count: number
data: number; data: number
children: ItemTree[]; children: ItemTree[]
} }
/** /**
* Represents champion build information * Represents champion build information
*/ */
interface Builds { interface Builds {
start: Array<{count: number, data: number}>; start: Array<{ count: number; data: number }>
tree: ItemTree; tree: ItemTree
bootsFirst: number; bootsFirst: number
boots: Array<{count: number, data: number}>; boots: Array<{ count: number; data: number }>
lateGame: Array<{count: number, data: number}>; lateGame: Array<{ count: number; data: number }>
suppItems?: Array<{count: number, data: number}>; suppItems?: Array<{ count: number; data: number }>
} }
/** /**
* Represents a rune configuration * Represents a rune configuration
*/ */
interface Rune { interface Rune {
count: number; count: number
primaryStyle: number; primaryStyle: number
secondaryStyle: number; secondaryStyle: number
selections: number[]; selections: number[]
pickrate: number; pickrate: number
} }
/** /**
* Represents lane-specific champion data * Represents lane-specific champion data
*/ */
interface LaneData { interface LaneData {
data: string; data: string
count: number; count: number
winningMatches: number; winningMatches: number
losingMatches: number; losingMatches: number
winrate: number; winrate: number
pickrate: number; pickrate: number
runes?: Rune[]; runes?: Rune[]
builds?: Builds; builds?: Builds
} }
/** /**
* Represents complete champion data * Represents complete champion data
*/ */
interface ChampionData { interface ChampionData {
id: number; id: number
name: string; name: string
alias: string; alias: string
gameCount: number; gameCount: number
winrate: number; winrate: number
pickrate: number; pickrate: number
lanes: LaneData[]; lanes: LaneData[]
} }
/** /**
* Champion summary from CDragon * Champion summary from CDragon
*/ */
interface ChampionSummary { interface ChampionSummary {
id: number; id: number
name: string; name: string
alias: string; alias: string
squarePortraitPath: string; squarePortraitPath: string
// Add other relevant fields as needed // Add other relevant fields as needed
} }
} }
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 */ /* Lanes */
const POSITIONS = ["top", "jungle", "middle", "bottom", "utility"] const POSITIONS = ['top', 'jungle', 'middle', 'bottom', 'utility']
const POSITIONS_STR = ["top", "jungle", "mid", "bot", "support"] 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 = Array(5)
const LANE_IMAGES_HOVER = Array(5).fill("").map((_, index) => "/img/lanes/icon-position-" + POSITIONS[index] + "-hover.png") .fill('')
const LANE_IMAGES_SELECTED = Array(5).fill("").map((_, index) => "/img/lanes/icon-position-" + POSITIONS[index] + "-blue.png") .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) { function laneIndexToPosition(index: number) {
switch (index) { switch (index) {
case 0: return "top" case 0:
case 1: return "jungle" return 'top'
case 2: return "middle" case 1:
case 3: return "bottom" return 'jungle'
case 4: return "utility" case 2:
return 'middle'
case 3:
return 'bottom'
case 4:
return 'utility'
} }
return null return null
} }
function lanePositionToIndex(position: string) { function lanePositionToIndex(position: string) {
const p = position.toLowerCase() const p = position.toLowerCase()
for (let i = 0; i < POSITIONS.length; i++) { for (let i = 0; i < POSITIONS.length; i++) {
if(p == POSITIONS[i]) return i; if (p == POSITIONS[i]) return i
} }
return -1; return -1
} }
function mapPath(assetPath: string) { function mapPath(assetPath: string) {
if(assetPath === undefined || assetPath === null) return "" if (assetPath === undefined || assetPath === null) return ''
return assetPath.toLowerCase().replace("/lol-game-data/assets/", "plugins/rcp-be-lol-game-data/global/default/") return assetPath
.toLowerCase()
.replace('/lol-game-data/assets/', 'plugins/rcp-be-lol-game-data/global/default/')
} }
export { export {
mapPath, CDRAGON_BASE, laneIndexToPosition, lanePositionToIndex, mapPath,
POSITIONS, LANE_IMAGES, LANE_IMAGES_HOVER, LANE_IMAGES_SELECTED, POSITIONS_STR 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 * @param wait Time in milliseconds to wait before calling the function
* @returns Debounced function * @returns Debounced function
*/ */
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void { export function debounce<T extends (...args: any[]) => any>(
let timeout: ReturnType<typeof setTimeout> | null = null; func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null
return function (...args: Parameters<T>): void { return function (...args: Parameters<T>): void {
if (timeout) { if (timeout) {
clearTimeout(timeout); clearTimeout(timeout)
}
timeout = setTimeout(() => func(...args), wait)
} }
timeout = setTimeout(() => func(...args), wait);
};
} }
/** /**
@@ -27,10 +30,10 @@ export function debounce<T extends (...args: any[]) => any>(func: T, wait: numbe
*/ */
export function safeJsonParse<T>(data: string, defaultValue: T): T { export function safeJsonParse<T>(data: string, defaultValue: T): T {
try { try {
return JSON.parse(data) as T; return JSON.parse(data) as T
} catch (error) { } catch (error) {
console.error('JSON parse error:', error); console.error('JSON parse error:', error)
return defaultValue; return defaultValue
} }
} }
@@ -41,7 +44,7 @@ export function safeJsonParse<T>(data: string, defaultValue: T): T {
* @returns Formatted percentage string * @returns Formatted percentage string
*/ */
export function formatPercentage(value: number, decimals: number = 0): 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 * @returns Capitalized string
*/ */
export function capitalize(str: string): string { export function capitalize(str: string): string {
if (!str) return ''; if (!str) return ''
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
} }
/** /**
@@ -61,13 +64,13 @@ export function capitalize(str: string): string {
*/ */
export function getLaneName(position: string): string { export function getLaneName(position: string): string {
const laneMap: Record<string, string> = { const laneMap: Record<string, string> = {
'top': 'Top', top: 'Top',
'jungle': 'Jungle', jungle: 'Jungle',
'middle': 'Middle', middle: 'Middle',
'bottom': 'Bottom', bottom: 'Bottom',
'utility': 'Support' utility: 'Support'
}; }
return laneMap[position.toLowerCase()] || position; return laneMap[position.toLowerCase()] || position
} }
/** /**
@@ -76,7 +79,7 @@ export function getLaneName(position: string): string {
* @returns Full image URL * @returns Full image URL
*/ */
export function getChampionImageUrl(championAlias: string): string { 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 * @returns Full item image URL
*/ */
export function getItemImageUrl(itemId: number): string { 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 * @returns Full rune image URL
*/ */
export function getRuneImageUrl(runeId: number): string { 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`
} }
/** /**
@@ -104,11 +107,11 @@ export function getRuneImageUrl(runeId: number): string {
*/ */
export function formatLargeNumber(num: number): string { export function formatLargeNumber(num: number): string {
if (num >= 1000000) { if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'; return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) { } else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'; return (num / 1000).toFixed(1) + 'K'
} }
return num.toString(); return num.toString()
} }
/** /**
@@ -117,7 +120,7 @@ export function formatLargeNumber(num: number): string {
* @returns Cloned object * @returns Cloned object
*/ */
export function deepClone<T>(obj: T): T { 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 * @returns True if value is empty
*/ */
export function isEmpty(value: any): boolean { export function isEmpty(value: any): boolean {
if (value === null || value === undefined) return true; if (value === null || value === undefined) return true
if (typeof value === 'string' && value.trim() === '') return true; if (typeof value === 'string' && value.trim() === '') return true
if (Array.isArray(value) && value.length === 0) return true; if (Array.isArray(value) && value.length === 0) return true
if (typeof value === 'object' && Object.keys(value).length === 0) return true; if (typeof value === 'object' && Object.keys(value).length === 0) return true
return false; return false
} }
/** /**
@@ -139,9 +142,9 @@ export function isEmpty(value: any): boolean {
* @returns CSS color class * @returns CSS color class
*/ */
export function getWinrateColor(winrate: number): string { export function getWinrateColor(winrate: number): string {
if (winrate > 0.55) return 'text-green-500'; if (winrate > 0.55) return 'text-green-500'
if (winrate < 0.45) return 'text-red-500'; if (winrate < 0.45) return 'text-red-500'
return 'text-yellow-500'; return 'text-yellow-500'
} }
/** /**
@@ -150,15 +153,15 @@ export function getWinrateColor(winrate: number): string {
* @returns Formatted duration string * @returns Formatted duration string
*/ */
export function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60)
if (hours > 0) { if (hours > 0) {
return `${hours}h ${minutes % 60}m`; return `${hours}h ${minutes % 60}m`
} else if (minutes > 0) { } else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`; return `${minutes}m ${seconds % 60}s`
} else { } else {
return `${seconds}s`; return `${seconds}s`
} }
} }

View File

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

View File

@@ -1,29 +1,35 @@
function sameArrays(array1: Array<any>, array2: Array<any>) { function sameArrays(array1: Array<any>, array2: Array<any>) {
if(array1.length != array2.length) return false; if (array1.length != array2.length) return false
for(let e of array1) { for (const e of array1) {
if(!array2.includes(e)) return false; if (!array2.includes(e)) return false
} }
return true; return true
} }
import { MongoClient } from "mongodb"; import { MongoClient } from 'mongodb'
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from "./item_tree"; import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from './item_tree'
const itemDict = new Map() const itemDict = new Map()
async function itemList() { async function itemList() {
const response = await fetch("https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json") const response = await fetch(
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json'
)
const list = await response.json() const list = await response.json()
return list return list
} }
function arrayRemovePercentage(array: Array<{count:number}>, totalGames:number, percentage: number) { function arrayRemovePercentage(
let toRemove : Array<{count:number}> = [] array: Array<{ count: number }>,
for(let item of array) { totalGames: number,
if((item.count/totalGames) < percentage) { percentage: number
) {
const toRemove: Array<{ count: number }> = []
for (const item of array) {
if (item.count / totalGames < percentage) {
toRemove.push(item) toRemove.push(item)
} }
} }
for(let tr of toRemove) { for (const tr of toRemove) {
array.splice(array.indexOf(tr), 1) array.splice(array.indexOf(tr), 1)
} }
} }
@@ -34,19 +40,19 @@ type Rune = {
secondaryStyle: number secondaryStyle: number
selections: Array<number> selections: Array<number>
pickrate?: number pickrate?: number
}; }
type Builds = { type Builds = {
tree: ItemTree tree: ItemTree
start: Array<{data: number, count: number}> start: Array<{ data: number; count: number }>
bootsFirst: number bootsFirst: number
boots: Array<{data: number, count: number}> boots: Array<{ data: number; count: number }>
lateGame: Array<{data: number, count: number}> lateGame: Array<{ data: number; count: number }>
suppItems?: Array<{data: number, count: number}> suppItems?: Array<{ data: number; count: number }>
} }
type Champion = { type Champion = {
id: Number id: number
name: String name: string
alias: String alias: string
} }
type LaneData = { type LaneData = {
data: string data: string
@@ -69,18 +75,27 @@ function handleParticipantRunes(participant, runes: Array<Rune>) {
const primaryStyle = participant.perks.styles[0].style const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = [] const selections: Array<number> = []
for(let style of participant.perks.styles) { for (const style of participant.perks.styles) {
for(let perk of style.selections) { for (const perk of style.selections) {
selections.push(perk.perk) selections.push(perk.perk)
} }
} }
const gameRunes : Rune = {count:1, primaryStyle: primaryStyle, secondaryStyle: secondaryStyle, selections: selections}; const gameRunes: Rune = {
let addRunes = true; count: 1,
for(let rune of runes) { primaryStyle: primaryStyle,
if(rune.primaryStyle == gameRunes.primaryStyle secondaryStyle: secondaryStyle,
&& rune.secondaryStyle == gameRunes.secondaryStyle selections: selections
&& sameArrays(rune.selections, gameRunes.selections)) { }
rune.count++; addRunes = false; break; let addRunes = true
for (const rune of runes) {
if (
rune.primaryStyle == gameRunes.primaryStyle &&
rune.secondaryStyle == gameRunes.secondaryStyle &&
sameArrays(rune.selections, gameRunes.selections)
) {
rune.count++
addRunes = false
break
} }
} }
if (addRunes) runes.push(gameRunes) if (addRunes) runes.push(gameRunes)
@@ -88,45 +103,49 @@ function handleParticipantRunes(participant, runes: Array<Rune>) {
function handleMatchItems(timeline, participant: any, participantIndex: number, builds: Builds) { function handleMatchItems(timeline, participant: any, participantIndex: number, builds: Builds) {
const items: Array<number> = [] const items: Array<number> = []
for(let frame of timeline.info.frames) { for (const frame of timeline.info.frames) {
for(let event of frame.events) { for (const event of frame.events) {
if(event.participantId != participantIndex) continue; if (event.participantId != participantIndex) continue
if(event.type == "ITEM_UNDO") { if (event.type == 'ITEM_UNDO') {
if (items.length > 0 && items[items.length - 1] == event.beforeId) { if (items.length > 0 && items[items.length - 1] == event.beforeId) {
items.pop() items.pop()
} }
continue; continue
} }
let itemInfo = itemDict.get(event.itemId) const itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade // Handle bounty of worlds destroy as upgrade
if(event.type == "ITEM_DESTROYED") { if (event.type == 'ITEM_DESTROYED') {
if (event.itemId == 3867) { if (event.itemId == 3867) {
let suppItem : number = itemInfo.to.find((x:number) => const suppItem: number = itemInfo.to.find(
x == participant.item0 (x: number) =>
|| x == participant.item1 x == participant.item0 ||
|| x == participant.item2 x == participant.item1 ||
|| x == participant.item3 x == participant.item2 ||
|| x == participant.item4 x == participant.item3 ||
|| x == participant.item5 x == participant.item4 ||
|| x == participant.item6 ) x == participant.item5 ||
x == participant.item6
)
if (suppItem != undefined) { if (suppItem != undefined) {
const already = builds.suppItems.find((x) => x.data == suppItem) const already = builds.suppItems.find(x => x.data == suppItem)
if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem }) if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem })
else already.count += 1 else already.count += 1
} }
} }
} }
if(event.type != "ITEM_PURCHASED") continue; if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades // Handle boots upgrades
if(itemInfo.requiredBuffCurrencyName == "Feats_NoxianBootPurchaseBuff" if (
|| itemInfo.requiredBuffCurrencyName == "Feats_SpecialQuestBootBuff") { itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
continue; itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
} }
// Handle boots differently // Handle boots differently
if(itemInfo.categories.includes("Boots")){ if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) { if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst // Check for bootsFirst
if (items.length < 2) { if (items.length < 2) {
@@ -134,26 +153,26 @@ function handleMatchItems(timeline, participant: any, participantIndex : number,
} }
// Add to boots // Add to boots
const already = builds.boots.find((x) => x.data == event.itemId) const already = builds.boots.find(x => x.data == event.itemId)
if (already == undefined) builds.boots.push({ count: 1, data: event.itemId }) if (already == undefined) builds.boots.push({ count: 1, data: event.itemId })
else already.count += 1 else already.count += 1
} }
continue; continue
} }
// Check if item should be included // Check if item should be included
if(itemInfo.categories.includes("Consumable")) continue; if (itemInfo.categories.includes('Consumable')) continue
if(itemInfo.categories.includes("Trinket")) continue; if (itemInfo.categories.includes('Trinket')) continue
// Ignore zephyr // Ignore zephyr
if(event.itemId == 3172) continue; if (event.itemId == 3172) continue
// Ignore Cull as not-first item // Ignore Cull as not-first item
if(event.itemId == 1083 && items.length >= 1) continue; if (event.itemId == 1083 && items.length >= 1) continue
// Ignore non-final items, except when first item bought // Ignore non-final items, except when first item bought
if(itemInfo.to.length != 0 && items.length >= 1) continue; if (itemInfo.to.length != 0 && items.length >= 1) continue
items.push(event.itemId) items.push(event.itemId)
} }
@@ -164,43 +183,57 @@ function handleMatchItems(timeline, participant: any, participantIndex : number,
// Start items // Start items
if (items.length >= 1) { if (items.length >= 1) {
const already = builds.start.find((x) => x.data == items[0]) const already = builds.start.find(x => x.data == items[0])
if (already == undefined) builds.start.push({ count: 1, data: items[0] }) if (already == undefined) builds.start.push({ count: 1, data: items[0] })
else already.count += 1 else already.count += 1
} }
// Late game items // Late game items
for(let item of items.slice(3)) { for (const item of items.slice(3)) {
const already = builds.lateGame.find((x) => x.data == item) const already = builds.lateGame.find(x => x.data == item)
if (already == undefined) builds.lateGame.push({ count: 1, data: item }) if (already == undefined) builds.lateGame.push({ count: 1, data: item })
else already.count += 1 else already.count += 1
} }
} }
function handleMatch(match: any, champions: Map<number, ChampionData>) { function handleMatch(match: any, champions: Map<number, ChampionData>) {
let participantIndex = 0; let participantIndex = 0
for(let participant of match.info.participants) { for (const participant of match.info.participants) {
participantIndex += 1 participantIndex += 1
const championId = participant.championId const championId = participant.championId
const champion = champions.get(championId) const champion = champions.get(championId)
// Lanes // Lanes
let lane = champion.lanes.find((x) => x.data == participant.teamPosition) let lane = champion.lanes.find(x => x.data == participant.teamPosition)
if (lane == undefined) { if (lane == undefined) {
const builds : Builds = {tree:treeInit(), start: [], bootsFirst: 0, boots: [], lateGame: [], suppItems: []} const builds: Builds = {
lane = {count:1, data: participant.teamPosition, runes:[], builds:builds, winningMatches: 0, losingMatches: 0, winrate: 0, pickrate: 0} tree: treeInit(),
champion.lanes.push(lane) start: [],
bootsFirst: 0,
boots: [],
lateGame: [],
suppItems: []
} }
else lane.count += 1 lane = {
count: 1,
data: participant.teamPosition,
runes: [],
builds: builds,
winningMatches: 0,
losingMatches: 0,
winrate: 0,
pickrate: 0
}
champion.lanes.push(lane)
} else lane.count += 1
// Winrate // Winrate
if (participant.win) { if (participant.win) {
champion.winningMatches++; champion.winningMatches++
lane.winningMatches++; lane.winningMatches++
} } else {
else { champion.losingMatches++
champion.losingMatches++; lane.losingMatches++
lane.losingMatches++;
} }
// Runes // Runes
@@ -211,16 +244,22 @@ function handleMatch(match: any, champions : Map<number, ChampionData>) {
} }
} }
async function handleMatchList(client: MongoClient, patch: string, champions: Map<number, ChampionData>) { async function handleMatchList(
const database = client.db("matches"); client: MongoClient,
patch: string,
champions: Map<number, ChampionData>
) {
const database = client.db('matches')
const matches = database.collection(patch) const matches = database.collection(patch)
const allMatches = matches.find() const allMatches = matches.find()
const totalMatches: number = await matches.countDocuments() const totalMatches: number = await matches.countDocuments()
let currentMatch = 0; let currentMatch = 0
for await (let match of allMatches) { for await (const match of allMatches) {
process.stdout.write("\rComputing champion stats, game entry " + currentMatch + "/" + totalMatches + " ... ") process.stdout.write(
currentMatch += 1; '\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
)
currentMatch += 1
handleMatch(match, champions) handleMatch(match, champions)
} }
@@ -228,36 +267,33 @@ async function handleMatchList(client: MongoClient, patch: string, champions: Ma
} }
async function finalizeChampionStats(champion: ChampionData, totalMatches: number) { async function finalizeChampionStats(champion: ChampionData, totalMatches: number) {
let totalChampionMatches = champion.winningMatches + champion.losingMatches; const totalChampionMatches = champion.winningMatches + champion.losingMatches
arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2) arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2)
champion.lanes.sort((a, b) => b.count - a.count) champion.lanes.sort((a, b) => b.count - a.count)
// Filter runes to keep 3 most played // Filter runes to keep 3 most played
for(let lane of champion.lanes) { for (const lane of champion.lanes) {
const runes = lane.runes const runes = lane.runes
runes.sort((a, b) => b.count - a.count) runes.sort((a, b) => b.count - a.count)
if(runes.length > 3) if (runes.length > 3) runes.splice(3, runes.length - 3)
runes.splice(3, runes.length - 3)
// Compute runes pickrate // Compute runes pickrate
for(let rune of runes) for (const rune of runes) rune.pickrate = rune.count / lane.count
rune.pickrate = rune.count / lane.count;
} }
for(let lane of champion.lanes) { for (const lane of champion.lanes) {
const builds = lane.builds const builds = lane.builds
// Cut item tree branches to keep only 4 branches every time and with percentage threshold // Cut item tree branches to keep only 4 branches every time and with percentage threshold
builds.tree.count = lane.count; builds.tree.count = lane.count
treeCutBranches(builds.tree, 4, 0.05) treeCutBranches(builds.tree, 4, 0.05)
treeSort(builds.tree) treeSort(builds.tree)
// Cut item start, to only 4 and with percentage threshold // Cut item start, to only 4 and with percentage threshold
arrayRemovePercentage(builds.start, lane.count, 0.05) arrayRemovePercentage(builds.start, lane.count, 0.05)
builds.start.sort((a, b) => b.count - a.count) builds.start.sort((a, b) => b.count - a.count)
if(builds.start.length > 4) if (builds.start.length > 4) builds.start.splice(4, builds.start.length - 4)
builds.start.splice(4, builds.start.length - 4)
// Remove boots that are not within percentage threshold // Remove boots that are not within percentage threshold
arrayRemovePercentage(builds.boots, lane.count, 0.05) arrayRemovePercentage(builds.boots, lane.count, 0.05)
@@ -268,8 +304,7 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
// Cut supp items below 2 and percentage threshold // Cut supp items below 2 and percentage threshold
arrayRemovePercentage(builds.suppItems, lane.count, 0.05) arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
builds.suppItems.sort((a, b) => b.count - a.count) builds.suppItems.sort((a, b) => b.count - a.count)
if(builds.suppItems.length > 2) if (builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2)
builds.suppItems.splice(2, builds.suppItems.length - 2)
// Delete supp items if empty // Delete supp items if empty
if (builds.suppItems.length == 0) delete builds.suppItems if (builds.suppItems.length == 0) delete builds.suppItems
@@ -277,39 +312,42 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
builds.lateGame.sort((a, b) => b.count - a.count) builds.lateGame.sort((a, b) => b.count - a.count)
} }
for(let lane of champion.lanes) { for (const lane of champion.lanes) {
lane.winrate = lane.winningMatches / lane.count lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches lane.pickrate = lane.count / totalMatches
} }
return {name: champion.champion.name, return {
name: champion.champion.name,
alias: champion.champion.alias.toLowerCase(), alias: champion.champion.alias.toLowerCase(),
id: champion.champion.id, id: champion.champion.id,
lanes: champion.lanes, lanes: champion.lanes,
winrate: champion.winningMatches / totalChampionMatches, winrate: champion.winningMatches / totalChampionMatches,
gameCount: totalChampionMatches, gameCount: totalChampionMatches,
pickrate: totalChampionMatches/totalMatches, pickrate: totalChampionMatches / totalMatches
}; }
} }
async function championList() { async function championList() {
const response = await fetch("https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json"); const response = await fetch(
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const list = await response.json() const list = await response.json()
return list.slice(1) return list.slice(1)
} }
async function makeChampionsStats(client: MongoClient, patch: string) { async function makeChampionsStats(client: MongoClient, patch: string) {
var globalItems = await itemList() const globalItems = await itemList()
for(let item of globalItems) { for (const item of globalItems) {
itemDict.set(item.id, item) itemDict.set(item.id, item)
} }
const list = await championList() const list = await championList()
console.log("Generating stats for " + list.length + " champions") console.log('Generating stats for ' + list.length + ' champions')
// Pre-generate list of champions // Pre-generate list of champions
const champions: Map<number, ChampionData> = new Map() const champions: Map<number, ChampionData> = new Map()
for(let champion of list) { for (const champion of list) {
champions.set(champion.id, { champions.set(champion.id, {
champion: { id: champion.id, name: champion.name, alias: champion.alias }, champion: { id: champion.id, name: champion.name, alias: champion.alias },
winningMatches: 0, winningMatches: 0,
@@ -322,9 +360,9 @@ async function makeChampionsStats(client: MongoClient, patch : string) {
const totalMatches = await handleMatchList(client, patch, champions) const totalMatches = await handleMatchList(client, patch, champions)
// Finalize and save stats for every champion // Finalize and save stats for every champion
const database = client.db("champions") const database = client.db('champions')
const collection = database.collection(patch) const collection = database.collection(patch)
for(let champion of list) { for (const champion of list) {
const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches) const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches)
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true }) await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
} }

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'eslint/config'
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default defineConfig([
js.configs.recommended,
tseslint.configs.recommended,
prettier,
{
rules: {
semi: 'off',
'prefer-const': 'error'
}
}
])

View File

@@ -1,65 +1,67 @@
const base = "https://euw1.api.riotgames.com" const base = 'https://euw1.api.riotgames.com'
const api_key = process.env.RIOT_API_KEY const api_key = process.env.RIOT_API_KEY
const sleep_minutes = 12 const sleep_minutes = 12
import { MongoClient } from 'mongodb' import { MongoClient } from 'mongodb'
import champion_stat from "./champion_stat" import champion_stat from './champion_stat'
main() main()
async function main() { async function main() {
// Check if we're in development mode with pre-loaded data // Check if we're in development mode with pre-loaded data
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') { if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
console.log("MatchCollector - Development mode with pre-loaded data"); console.log('MatchCollector - Development mode with pre-loaded data')
await runWithPreloadedData(); await runWithPreloadedData()
return; return
} }
// Original production mode // Original production mode
console.log("MatchCollector - Hello !"); console.log('MatchCollector - Hello !')
const client = await connectToDatabase(); const client = await connectToDatabase()
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client); const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
console.log("Connected to database, latest patch " + latestPatch + " was epoch: " + latestPatchTime) console.log(
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch); const alreadySeenGameList = await alreadySeenGames(client, latestPatch)
console.log("We already have " + alreadySeenGameList.length + " matches for this patch !") console.log('We already have ' + alreadySeenGameList.length + ' matches for this patch !')
console.log("Using RIOT_API_KEY: " + api_key) console.log('Using RIOT_API_KEY: ' + api_key)
if(api_key != null && api_key != undefined && api_key != "") { if (api_key != null && api_key != undefined && api_key != '') {
const challengerLeague = await fetchChallengerLeague(); const challengerLeague = await fetchChallengerLeague()
console.log("ChallengerLeague: got " + challengerLeague.entries.length + " entries"); console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
const gameList = []; const gameList = []
let i = 0; let i = 0
for(let challenger of challengerLeague.entries) { for (const challenger of challengerLeague.entries) {
console.log("Entry " + i + "/" + challengerLeague.entries.length + " ...") console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
const puuid = challenger.puuid; const puuid = challenger.puuid
const challengerGameList = await summonerGameList(puuid, latestPatchTime); const challengerGameList = await summonerGameList(puuid, latestPatchTime)
for(let game of challengerGameList) { for (const game of challengerGameList) {
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) { if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game) gameList.push(game)
} }
} }
i++; i++
} }
console.log("Games: got " + gameList.length + " entries"); console.log('Games: got ' + gameList.length + ' entries')
i = 0; i = 0
for(let game of gameList) { for (const game of gameList) {
console.log("Entry " + i + "/" + gameList.length + " ...") console.log('Entry ' + i + '/' + gameList.length + ' ...')
const gameMatch = await match(game) const gameMatch = await match(game)
const gameTimeline = await matchTimeline(game) const gameTimeline = await matchTimeline(game)
gameMatch.timeline = gameTimeline gameMatch.timeline = gameTimeline
await saveMatch(client, gameMatch, latestPatch) await saveMatch(client, gameMatch, latestPatch)
i++; i++
} }
} }
console.log("Generating stats..."); console.log('Generating stats...')
await champion_stat.makeChampionsStats(client, latestPatch) await champion_stat.makeChampionsStats(client, latestPatch)
console.log("All done. Closing client."); console.log('All done. Closing client.')
await client.close() await client.close()
} }
@@ -75,15 +77,27 @@ async function handleRateLimit(url : URL) : Promise<Response> {
function handleError(response: Response) { function handleError(response: Response) {
if (!response.ok) { if (!response.ok) {
console.log("Error during fetch(" + response.url + "): STATUS " + response.status + " (" + response.statusText + ")"); console.log(
process.exit(1); 'Error during fetch(' +
response.url +
'): STATUS ' +
response.status +
' (' +
response.statusText +
')'
)
process.exit(1)
} }
} }
async function connectToDatabase() { async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version // 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}` 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 != "") { if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI uri = process.env.MONGO_URI
} }
const client = new MongoClient(uri) const client = new MongoClient(uri)
@@ -92,71 +106,71 @@ async function connectToDatabase() {
} }
async function fetchLatestPatchDate(client) { async function fetchLatestPatchDate(client) {
const database = client.db("patches"); const database = client.db('patches')
const patches = database.collection("patches"); const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next() const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return [latestPatch.patch, Math.floor(latestPatch.date.valueOf() / 1000)] return [latestPatch.patch, Math.floor(latestPatch.date.valueOf() / 1000)]
} }
async function fetchChallengerLeague() { async function fetchChallengerLeague() {
const queue = "RANKED_SOLO_5x5" const queue = 'RANKED_SOLO_5x5'
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}` const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
const url = `${base}${endpoint}?api_key=${api_key}` const url = `${base}${endpoint}?api_key=${api_key}`
const challengerLeagueResponse = await handleRateLimit(new URL(url)); const challengerLeagueResponse = await handleRateLimit(new URL(url))
handleError(challengerLeagueResponse) handleError(challengerLeagueResponse)
const challengerLeague = await challengerLeagueResponse.json(); const challengerLeague = await challengerLeagueResponse.json()
return challengerLeague; return challengerLeague
} }
async function summonerGameList(puuid, startTime) { async function summonerGameList(puuid, startTime) {
const base = "https://europe.api.riotgames.com" const base = 'https://europe.api.riotgames.com'
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`; const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
const url = `${base}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}` const url = `${base}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
const gameListResponse = await handleRateLimit(new URL(url)); const gameListResponse = await handleRateLimit(new URL(url))
handleError(gameListResponse) handleError(gameListResponse)
const gameList = await gameListResponse.json(); const gameList = await gameListResponse.json()
return gameList; return gameList
} }
async function match(matchId) { async function match(matchId) {
const base = "https://europe.api.riotgames.com" const base = 'https://europe.api.riotgames.com'
const endpoint = `/lol/match/v5/matches/${matchId}` const endpoint = `/lol/match/v5/matches/${matchId}`
const url = `${base}${endpoint}?api_key=${api_key}` const url = `${base}${endpoint}?api_key=${api_key}`
const matchResponse = await handleRateLimit(new URL(url)) const matchResponse = await handleRateLimit(new URL(url))
handleError(matchResponse) handleError(matchResponse)
const match = await matchResponse.json(); const match = await matchResponse.json()
return match; return match
} }
async function matchTimeline(matchId) { async function matchTimeline(matchId) {
const base = "https://europe.api.riotgames.com" const base = 'https://europe.api.riotgames.com'
const endpoint = `/lol/match/v5/matches/${matchId}/timeline` const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
const url = `${base}${endpoint}?api_key=${api_key}` const url = `${base}${endpoint}?api_key=${api_key}`
const timelineResponse = await handleRateLimit(new URL(url)) const timelineResponse = await handleRateLimit(new URL(url))
handleError(timelineResponse) handleError(timelineResponse)
const timeline = await timelineResponse.json(); const timeline = await timelineResponse.json()
return timeline return timeline
} }
async function alreadySeenGames(client, latestPatch) { async function alreadySeenGames(client, latestPatch) {
const database = client.db("matches") const database = client.db('matches')
const matches = database.collection(latestPatch) const matches = database.collection(latestPatch)
const alreadySeen = await matches.distinct("metadata.matchId") const alreadySeen = await matches.distinct('metadata.matchId')
return alreadySeen return alreadySeen
} }
async function saveMatch(client, match, latestPatch) { async function saveMatch(client, match, latestPatch) {
const database = client.db("matches") const database = client.db('matches')
const matches = database.collection(latestPatch) const matches = database.collection(latestPatch)
await matches.insertOne(match) await matches.insertOne(match)
} }
@@ -165,43 +179,40 @@ async function saveMatch(client, match, latestPatch) {
* Development mode function that generates stats from pre-loaded data * Development mode function that generates stats from pre-loaded data
*/ */
async function runWithPreloadedData() { async function runWithPreloadedData() {
console.log("Using pre-loaded match data for development"); console.log('Using pre-loaded match data for development')
const client = await connectToDatabase(); const client = await connectToDatabase()
try { try {
const [latestPatch] = await fetchLatestPatchDate(client); const [latestPatch] = await fetchLatestPatchDate(client)
console.log(`Latest patch: ${latestPatch}`); console.log(`Latest patch: ${latestPatch}`)
// Check if we have matches for this patch // Check if we have matches for this patch
const matchesDb = client.db("matches"); const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray(); const collections = await matchesDb.listCollections().toArray()
const patchCollections = collections const patchCollections = collections.map(c => c.name).filter(name => name === latestPatch)
.map(c => c.name)
.filter(name => name === latestPatch);
if (patchCollections.length === 0) { if (patchCollections.length === 0) {
console.error(`❌ No match data found for patch ${latestPatch}`); console.error(`❌ No match data found for patch ${latestPatch}`)
console.log("💡 Please run the data import script first:"); console.log('💡 Please run the data import script first:')
console.log(" node dev/scripts/setup-db.js"); console.log(' node dev/scripts/setup-db.js')
return; return
} }
console.log(`Found ${patchCollections.length} match collection(s)`); console.log(`Found ${patchCollections.length} match collection(s)`)
// Generate stats for each patch with data // Generate stats for each patch with data
for (const patch of patchCollections) { for (const patch of patchCollections) {
console.log(`Generating stats for patch ${patch}...`); console.log(`Generating stats for patch ${patch}...`)
await champion_stat.makeChampionsStats(client, patch); await champion_stat.makeChampionsStats(client, patch)
console.log(`Stats generated for patch ${patch}`); console.log(`Stats generated for patch ${patch}`)
} }
console.log("🎉 All stats generated successfully!"); console.log('🎉 All stats generated successfully!')
console.log("🚀 Your development database is ready for frontend testing!"); console.log('🚀 Your development database is ready for frontend testing!')
} catch (error) { } catch (error) {
console.error("❌ Error in development mode:", error); console.error('❌ Error in development mode:', error)
throw error; throw error
} finally { } finally {
await client.close(); await client.close()
} }
} }

View File

@@ -2,13 +2,12 @@ type ItemTree = {
data: any data: any
count: number count: number
children: Array<ItemTree> children: Array<ItemTree>
}; }
function treeInit(): ItemTree { function treeInit(): ItemTree {
return { data: undefined, count: 0, children: [] } return { data: undefined, count: 0, children: [] }
} }
function treeNode(data: number, count: number): ItemTree { function treeNode(data: number, count: number): ItemTree {
return { data: data, count: count, children: [] } return { data: data, count: count, children: [] }
} }
@@ -17,16 +16,16 @@ function treeNode(data : number, count : number) : ItemTree {
* Merge a node with an item tree * Merge a node with an item tree
*/ */
function nodeMerge(itemtree: ItemTree, node: ItemTree) { function nodeMerge(itemtree: ItemTree, node: ItemTree) {
const item = node.data; const item = node.data
const count = node.count; const count = node.count
let next : ItemTree | null = null; let next: ItemTree | null = null
// Try to find an existing node in this tree level with same item // Try to find an existing node in this tree level with same item
for(let node of itemtree.children) { for (const node of itemtree.children) {
if (node.data == item) { if (node.data == item) {
node.count += 1; node.count += 1
next = node; next = node
break; break
} }
} }
@@ -36,16 +35,16 @@ function nodeMerge(itemtree : ItemTree, node : ItemTree) {
itemtree.children.push(next) itemtree.children.push(next)
} }
return next; return next
} }
/* /*
* Merge a full build path with an existing item tree * Merge a full build path with an existing item tree
*/ */
function treeMerge(itemtree: ItemTree, items: Array<number>) { function treeMerge(itemtree: ItemTree, items: Array<number>) {
let current = itemtree; let current = itemtree
for(let item of items) { for (const item of items) {
current = nodeMerge(current, { data: item, count: 1, children: [] }) current = nodeMerge(current, { data: item, count: 1, children: [] })
} }
} }
@@ -53,27 +52,30 @@ function treeMerge(itemtree : ItemTree, items : Array<number>) {
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) { function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
// Remove branches that are above threshold count // Remove branches that are above threshold count
while (itemtree.children.length > thresholdCount) { while (itemtree.children.length > thresholdCount) {
let leastUsedBranch = itemtree.children.reduce((a, b) => Math.min(a.count, b.count) == a.count ? a : b, {data:undefined, count: +Infinity, children: []}) const leastUsedBranch = itemtree.children.reduce(
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
{ data: undefined, count: +Infinity, children: [] }
)
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1) itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
} }
// Remove branches that are of too low usage // Remove branches that are of too low usage
let toRemove : Array<ItemTree> = [] const toRemove: Array<ItemTree> = []
for(let child of itemtree.children) { for (const child of itemtree.children) {
if((child.count/itemtree.count) < thresholdPerc) { if (child.count / itemtree.count < thresholdPerc) {
toRemove.push(child) toRemove.push(child)
} }
} }
for(let tr of toRemove) { for (const tr of toRemove) {
itemtree.children.splice(itemtree.children.indexOf(tr), 1) itemtree.children.splice(itemtree.children.indexOf(tr), 1)
} }
itemtree.children.map((x) => treeCutBranches(x, thresholdCount, thresholdPerc)) itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc))
} }
function treeMergeTree(itemtree1: ItemTree, itemtree2: ItemTree) { function treeMergeTree(itemtree1: ItemTree, itemtree2: ItemTree) {
for(let child of itemtree2.children) { for (const child of itemtree2.children) {
let node = nodeMerge(itemtree1, child) const node = nodeMerge(itemtree1, child)
treeMergeTree(node, child) treeMergeTree(node, child)
} }
} }
@@ -81,7 +83,7 @@ function treeMergeTree(itemtree1: ItemTree, itemtree2: ItemTree) {
function treeSort(itemtree: ItemTree) { function treeSort(itemtree: ItemTree) {
itemtree.children.sort((a, b) => b.count - a.count) itemtree.children.sort((a, b) => b.count - a.count)
for(let item of itemtree.children) { for (const item of itemtree.children) {
treeSort(item) treeSort(item)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,11 @@
"main": "index.ts", "main": "index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@@ -13,7 +17,15 @@
"mongodb": "^6.10.0" "mongodb": "^6.10.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^22.9.1", "@types/node": "^22.9.1",
"tsx": "^4.19.2" "@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.0",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1"
} }
} }

View File

@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"]
},
} }
}

View File

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

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'eslint/config'
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default defineConfig([
js.configs.recommended,
tseslint.configs.recommended,
prettier,
{
rules: {
semi: 'off',
'prefer-const': 'error'
}
}
])

View File

@@ -1,4 +1,4 @@
import { MongoClient } from "mongodb"; import { MongoClient } from 'mongodb'
main() main()
@@ -6,7 +6,7 @@ async function main() {
const client = await connectToDatabase() const client = await connectToDatabase()
const newPatch = await fetchLatestPatch() const newPatch = await fetchLatestPatch()
console.log("Latest patch is: " + newPatch) console.log('Latest patch is: ' + newPatch)
const newDate = new Date() const newDate = new Date()
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) { if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
@@ -16,19 +16,22 @@ async function main() {
await client.close() await client.close()
} }
async function fetchLatestPatch() { async function fetchLatestPatch() {
const url = "https://ddragon.leagueoflegends.com/api/versions.json" const url = 'https://ddragon.leagueoflegends.com/api/versions.json'
const patchDataResponse = await fetch(url); const patchDataResponse = await fetch(url)
const patchData = await patchDataResponse.json(); const patchData = await patchDataResponse.json()
const patch : string = patchData[0]; const patch: string = patchData[0]
return patch; return patch
} }
async function connectToDatabase() { async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version // 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}` 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 != "") { if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI uri = process.env.MONGO_URI
} }
const client = new MongoClient(uri) const client = new MongoClient(uri)
@@ -37,14 +40,14 @@ async function connectToDatabase() {
} }
async function compareLatestSavedPatch(client: MongoClient, newPatch: string, newDate: Date) { async function compareLatestSavedPatch(client: MongoClient, newPatch: string, newDate: Date) {
const database = client.db("patches") const database = client.db('patches')
const patches = database.collection("patches") const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next() const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
if (latestPatch == null) { if (latestPatch == null) {
console.log("No previous patch recorded in database.") console.log('No previous patch recorded in database.')
} else { } else {
console.log("Latest patch in database is: " + latestPatch.patch) console.log('Latest patch in database is: ' + latestPatch.patch)
} }
if (latestPatch == null || latestPatch.patch != newPatch) { if (latestPatch == null || latestPatch.patch != newPatch) {
@@ -55,6 +58,4 @@ async function compareLatestSavedPatch(client: MongoClient, newPatch : string, n
return true return true
} }
async function downloadAssets() { async function downloadAssets() {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,13 @@
"name": "patch_detector", "name": "patch_detector",
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"type": "commonjs", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@@ -13,7 +17,15 @@
"mongodb": "^6.10.0" "mongodb": "^6.10.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"tsx": "^4.19.2" "@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.0",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1"
} }
} }

View File

@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"]
},
} }
}