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,15 +3,15 @@ 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>
<template> <template>
<div> <div>
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</div> </div>
</template> </template>

View File

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

View File

@@ -1,305 +1,327 @@
<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>
<!-- Error state -->
<div v-else-if="hasErrors" class="error-state">
<p>Failed to load champion data. Please refresh the page.</p>
</div>
<!-- Main content -->
<div v-else>
<div class="search-lanefilter-container">
<LaneFilter
id="cs-lanefilter"
@filter-change="filterChampionsByLane"
/>
<input
@keyup.enter="() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)"
v-model="searchText"
ref="searchBar"
class="search-bar"
type="text"
placeholder="Search a champion"
/>
</div>
<!-- Empty state -->
<div v-if="filteredChampions.length === 0" class="empty-state">
<p>No champions found. Try a different search or filter.</p>
</div>
<div v-else class="champion-container">
<NuxtLink
v-for="champion in filteredChampions"
:key="champion.id"
:to="'/champion/' + champion.alias.toLowerCase()"
style="width: fit-content; height: fit-content;"
>
<div class="cs-champion-img-container">
<NuxtImg
format="webp"
class="cs-champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</div>
</div>
</div> </div>
<!-- Error state -->
<div v-else-if="hasErrors" class="error-state">
<p>Failed to load champion data. Please refresh the page.</p>
</div>
<!-- Main content -->
<div v-else>
<div class="search-lanefilter-container">
<LaneFilter id="cs-lanefilter" @filter-change="filterChampionsByLane" />
<input
ref="searchBar"
v-model="searchText"
class="search-bar"
type="text"
placeholder="Search a champion"
@keyup.enter="
() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)
"
>
</div>
<!-- Empty state -->
<div v-if="filteredChampions.length === 0" class="empty-state">
<p>No champions found. Try a different search or filter.</p>
</div>
<div v-else class="champion-container">
<NuxtLink
v-for="champion in filteredChampions"
:key="champion.id"
:to="'/champion/' + champion.alias.toLowerCase()"
style="width: fit-content; height: fit-content"
>
<div class="cs-champion-img-container">
<NuxtImg
format="webp"
class="cs-champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</div>
</div>
</div>
</template> </template>
<style> <style>
/* Loading and error states */ /* Loading and error states */
.loading-state, .error-state, .empty-state { .loading-state,
text-align: center; .error-state,
padding: 40px 20px; .empty-state {
color: var(--color-on-surface); text-align: center;
font-size: 1.2rem; padding: 40px 20px;
color: var(--color-on-surface);
font-size: 1.2rem;
} }
.loading-state { .loading-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.loading-spinner { .loading-spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid var(--color-surface); border: 4px solid var(--color-surface);
border-top: 4px solid var(--color-primary); border-top: 4px solid var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@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 {
margin: 0; margin: 0;
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
opacity: 0.6; 100% {
} opacity: 0.6;
50% { }
opacity: 1; 50% {
} opacity: 1;
}
} }
.error-state { .error-state {
color: var(--color-error); color: var(--color-error);
} }
.empty-state { .empty-state {
opacity: 0.7; opacity: 0.7;
} }
.search-bar { .search-bar {
width: 400px; width: 400px;
height: 60px; height: 60px;
background-color: var(--color-surface-darker); background-color: var(--color-surface-darker);
font-size: 1.75rem;
border-radius: 12px; font-size: 1.75rem;
border: none;
padding-left: 10px; border-radius: 12px;
border: none;
padding-left: 10px;
} }
.search-bar:focus { .search-bar:focus {
border: 2px solid var(--color-on-surface); border: 2px solid var(--color-on-surface);
outline: none; outline: none;
} }
#cs-lanefilter { #cs-lanefilter {
margin: auto; margin: auto;
margin-right: 20px; margin-right: 20px;
} }
.search-lanefilter-container { .search-lanefilter-container {
width: fit-content; width: fit-content;
margin: auto; margin: auto;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.champion-container { .champion-container {
width: 90%; width: 90%;
height: auto; height: auto;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, 128px); grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px; grid-gap: 10px;
justify-content: center; justify-content: center;
margin: auto; margin: auto;
margin-top: 20px; margin-top: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.cs-champion-img-container { .cs-champion-img-container {
overflow: hidden; width: 120px; height: 120px; overflow: hidden;
border: 1px solid var(--color-surface); width: 120px;
height: 120px;
border: 1px solid var(--color-surface);
} }
.cs-champion-img-container:hover { .cs-champion-img-container:hover {
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
} }
.cs-champion-img { .cs-champion-img {
width: 116px; width: 116px;
height: 116px; height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2); transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none; user-select: none;
} }
@media only screen and (max-width: 920px) { @media only screen and (max-width: 920px) {
.search-lanefilter-container { .search-lanefilter-container {
flex-direction: column; flex-direction: column;
} }
#cs-lanefilter { #cs-lanefilter {
margin-right: auto; margin-right: auto;
} }
.champion-container { .champion-container {
width: 100%; width: 100%;
} }
} }
@media only screen and (max-width: 450px) { @media only screen and (max-width: 450px) {
.search-bar { .search-bar {
width: 92%; width: 92%;
height: 40px; height: 40px;
} }
.cs-champion-img-container { .cs-champion-img-container {
width: 80px; width: 80px;
height: 80px; height: 80px;
} }
.cs-champion-img { .cs-champion-img {
width: 76px; width: 76px;
height: 76px; height: 76px;
} }
.champion-container { .champion-container {
grid-template-columns: repeat(auto-fit, 80px); grid-template-columns: repeat(auto-fit, 80px);
} }
.search-lanefilter-container { .search-lanefilter-container {
margin-top: 10px; margin-top: 10px;
} }
} }
</style> </style>

View File

@@ -1,98 +1,122 @@
<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
<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'"/> width="160"
</div> height="160"
class="champion-title-img"
<div id="ct-info-container"> :src="
<h1>{{ championName }}</h1> CDRAGON_BASE +
<h3 id="ct-desc">{{ championDescription }}</h3> 'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' +
championId +
<div id="ct-basic-stat-container"> '.png'
<h2 class="ct-basic-stat">{{ winrate }}% win.</h2> "
<h2 class="ct-basic-stat ct-basic-stat-margin">{{ pickrate }}% pick.</h2> />
<h2 class="ct-basic-stat">{{ gameCount }} games</h2>
</div>
</div>
</div> </div>
<div id="ct-info-container">
<h1>{{ championName }}</h1>
<h3 id="ct-desc">{{ championDescription }}</h3>
<div id="ct-basic-stat-container">
<h2 class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 class="ct-basic-stat ct-basic-stat-margin">{{ pickrate }}% pick.</h2>
<h2 class="ct-basic-stat">{{ gameCount }} games</h2>
</div>
</div>
</div>
</template> </template>
<style> <style>
.champion-title-img-container { .champion-title-img-container {
width: 160px; width: 160px;
height: 160px; height: 160px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
} }
.champion-title-img { .champion-title-img {
width: 160px; width: 160px;
height: 160px; height: 160px;
transform: translate(4px, 4px) scale(1.2, 1.2); transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none; user-select: none;
} }
#ct-info-container { #ct-info-container {
margin-left: 15px; margin-left: 15px;
margin-top: 5px; margin-top: 5px;
} }
.ct-basic-stat-margin { .ct-basic-stat-margin {
margin-left: 20px; margin-left: 20px;
margin-right: 20px; margin-right: 20px;
} }
#ct-desc { #ct-desc {
margin-top: 5px; margin-top: 5px;
} }
#ct-basic-stat-container { #ct-basic-stat-container {
margin-top: 30px; margin-top: 30px;
display: flex; display: flex;
} }
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
.champion-title-img-container { .champion-title-img-container {
width: 86px; width: 86px;
height: 86px; height: 86px;
} }
.champion-title-img { .champion-title-img {
width: 86px; width: 86px;
height: 86px; height: 86px;
} }
#ct-desc { #ct-desc {
display: none; display: none;
} }
.ct-basic-stat { .ct-basic-stat {
text-align: center; text-align: center;
} }
.ct-basic-stat-margin { .ct-basic-stat-margin {
margin-left: 2px; margin-left: 2px;
margin-right: 2px; margin-right: 2px;
} }
#ct-basic-stat-container { #ct-basic-stat-container {
margin-top: 10px; margin-top: 10px;
} }
#ct-info-container { #ct-info-container {
margin-left: 10px; margin-left: 10px;
margin-top: 0px; margin-top: 0px;
max-width: 220px; max-width: 220px;
} }
} }
</style> </style>

View File

@@ -1,67 +1,75 @@
<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) {
// Unselect previous filter // Unselect previous filter
if(laneFilter.value != -1) { if (laneFilter.value != -1) {
laneImgs[laneFilter.value].value = LANE_IMAGES[laneFilter.value] laneImgs[laneFilter.value].value = LANE_IMAGES[laneFilter.value]
// 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
}
} }
}
// Select new one // Select new one
laneImgs[index].value = LANE_IMAGES_SELECTED[index] laneImgs[index].value = LANE_IMAGES_SELECTED[index]
laneFilter.value = index laneFilter.value = index
emit('filterChange', laneFilter.value) emit('filterChange', laneFilter.value)
} }
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
format="webp" v-for="(laneImg, index) in laneImgs"
:alt="POSITIONS_STR[index]" format="webp"
class="lane-img" :src="laneImg.value" :alt="POSITIONS_STR[index]"
@mouseout="handleMouseOut(laneImg, index)" class="lane-img"
@mouseover="handleHover(laneImg, index)" :src="laneImg.value"
@click="selectLaneFilter(index)"/> @mouseout="handleMouseOut(laneImg, index)"
</div> @mouseover="handleHover(laneImg, index)"
@click="selectLaneFilter(index)"
/>
</div>
</template> </template>
<style> <style>
.lane-img { .lane-img {
width: 64px; width: 64px;
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
} }
.lane-img:hover { .lane-img:hover {
cursor: pointer; cursor: pointer;
} }
@media only screen and (max-width: 450px) { @media only screen and (max-width: 450px) {
.lane-img { .lane-img {
width: 48px; width: 48px;
} }
} }
</style> </style>

View File

@@ -1,44 +1,49 @@
<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
format="webp" id="logo-img"
:width="imgWidth == null ? '120' : Number(imgWidth)" alt="BuildPath"
src="/buildpath-high-resolution-logo-transparent.png" /> format="webp"
<h1 :style="'font-size: ' + (fontSize == null ? '5.0rem' : fontSize) + ';'" id="logo-text">BuildPath</h1> :width="imgWidth == null ? '120' : Number(imgWidth)"
src="/buildpath-high-resolution-logo-transparent.png"
/>
<h1 id="logo-text" :style="'font-size: ' + (fontSize == null ? '5.0rem' : fontSize) + ';'">
BuildPath
</h1>
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
<style> <style>
#logo-text { #logo-text {
font-weight: 200; font-weight: 200;
align-content: center; align-content: center;
margin-left: 20px; margin-left: 20px;
user-select: none; user-select: none;
text-decoration: none; text-decoration: none;
} }
#logo-img { #logo-img {
user-select: none; user-select: none;
} }
@media only screen and (max-width: 550px) { @media only screen and (max-width: 550px) {
#logo-text { #logo-text {
margin-left: 10px; margin-left: 10px;
} }
#logo-img { #logo-img {
max-width: 80px; max-width: 80px;
max-height: 103px; max-height: 103px;
height: fit-content; height: fit-content;
} }
} }
</style> </style>

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,13 +22,23 @@ 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"
class="object-cover" >
style="transform: translate(4px, 4px) scale(1.2, 1.2); width: 216px; height: 216px;" <NuxtImg
:src="CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' + id + '.png'" /> width="216px"
</div> height="216px"
class="object-cover"
style="transform: translate(4px, 4px) scale(1.2, 1.2); width: 216px; height: 216px"
:src="
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' +
id +
'.png'
"
/>
</div>
</div> </div>
</div> </div>
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between">
@@ -38,27 +47,21 @@ defineProps<{
<!-- <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#888888" d="m4.67 28l6.39-12l7.3 6.49a2 2 0 0 0 1.7.47a2 2 0 0 0 1.42-1.07L27 10.9l-1.82-.9l-5.49 11l-7.3-6.49a2 2 0 0 0-1.68-.51a2 2 0 0 0-1.42 1L4 25V2H2v26a2 2 0 0 0 2 2h26v-2Z" /></svg> --> <!-- <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,41 +1,49 @@
<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="
<h2 class="item-box-title">{{ title }}</h2> sizePerc != undefined && sizePerc != null ? 'max-height: ' + sizePerc * 600 + 'px;' : ''
<h5 v-if="bootsFirst != undefined && bootsFirst != null" "
style="margin: auto;">({{ (bootsFirst * 100).toFixed(2) }}%)</h5> class="item-box"
>
<div
style="display: flex; flex-direction: column; justify-content: center; align-items: center"
>
<h2 class="item-box-title">{{ title }}</h2>
<h5 v-if="bootsFirst != undefined && bootsFirst != null" style="margin: auto">
({{ (bootsFirst * 100).toFixed(2) }}%)
</h5>
</div> </div>
<slot/> <slot />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.item-box { .item-box {
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
border-radius: 8px; border-radius: 8px;
margin: 10px; margin: 10px;
width: fit-content; width: fit-content;
height: 600px; height: 600px;
} }
.item-box-title { .item-box-title {
font-variant: small-caps; font-variant: small-caps;
text-align: center; text-align: center;
margin: 10px; margin: 10px;
} }
@media only screen and (max-width: 1000px) { @media only screen and (max-width: 1000px) {
.item-box { .item-box {
width: 95%; width: 95%;
height: fit-content; height: fit-content;
} }
} }
</style> </style>

View File

@@ -1,112 +1,128 @@
<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 arrows: Array<svgdomarrows.LinePath> = []
const start : Ref<Element | null> = useTemplateRef("start")
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)
}) })
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: {
element: start, element: start,
position: { position: {
top: 0.5, top: 0.5,
left: 1 left: 1
} }
}, },
end: { end: {
element: end, element: end,
position: { position: {
top: 0.5, top: 0.5,
left: 0 left: 0
} }
}, },
style: 'stroke:var(--color-on-surface);stroke-width:3;fill:transparent;', style: 'stroke:var(--color-on-surface);stroke-width:3;fill:transparent;',
appendTo: document.body appendTo: document.body
}) })
arrows.push(arrow) arrows.push(arrow)
} }
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')
} }
function handleRefresh() { function handleRefresh() {
refreshArrows() refreshArrows()
emit('refresh') emit('refresh')
} }
</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"
:alt="tree.data.toString()" >
:src="CDRAGON_BASE + mapPath(itemMap.get(tree.data).iconPath)" /> <img
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%</h3> ref="start"
</div> class="item-img"
width="64"
<div style="margin-left: 30px;"> height="64"
<div style="width: fit-content; height: fit-content;" v-for="child in tree.children"> :alt="tree.data.toString()"
<ItemTree @refresh="handleRefresh" @mount="(end) => handleSubtreeMount(end)" :tree="child" :parent-count="tree.count" /> :src="CDRAGON_BASE + mapPath(itemMap.get(tree.data).iconPath)"
</div> >
</div> <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 v-for="child in tree.children" style="width: fit-content; height: fit-content">
<ItemTree
:tree="child"
:parent-count="tree.count"
@refresh="handleRefresh"
@mount="end => handleSubtreeMount(end)"
/>
</div>
</div>
</div>
</template> </template>

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(
try { items,
const itemsData = newItems || []; newItems => {
if (Array.isArray(itemsData)) { try {
const map = new Map<number, any>(); const itemsData = newItems || []
for (const item of itemsData) { if (Array.isArray(itemsData)) {
if (item?.id) { const map = new Map<number, any>()
map.set(item.id, item); for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item)
}
} }
itemMap.value = map
} }
itemMap.value = map; } catch (error) {
console.error('Error initializing item map:', error)
} }
} catch (error) { },
console.error('Error initializing item map:', error); { immediate: true }
} )
}, { immediate: true });
// Builds management // 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,161 +74,206 @@ 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 v-if="builds.suppItems == undefined || builds.suppItems == null" title="start">
<ItemBox title="start" v-if="builds.suppItems == undefined || builds.suppItems == null"> <div class="iv-items-container">
<div class="iv-items-container"> <div v-for="item in builds.start" style="margin-left: 5px; margin-right: 5px">
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.start" > <NuxtImg
<NuxtImg v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()" class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" /> width="64"
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3> height="64"
</div> :alt="item.data.toString()"
</div> :src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
</ItemBox> />
<!-- Supp items --> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
<ItemBox v-if="builds.suppItems != undefined && builds.suppItems != null" title="supp"> {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
<div class="iv-items-container"> </h3>
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.suppItems" > </div>
<NuxtImg v-if="item.data != null && item.data != undefined" </div>
class="item-img" width="64" height="64" :alt="item.data.toString()" </ItemBox>
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" /> <!-- Supp items -->
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3> <ItemBox v-if="builds.suppItems != undefined && builds.suppItems != null" title="supp">
</div> <div class="iv-items-container">
</div> <div v-for="item in builds.suppItems" style="margin-left: 5px; margin-right: 5px">
</ItemBox> <NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
</div>
<!-- Boots first : when champion rush boots -->
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :boots-first="builds.bootsFirst">
<div class="iv-items-container">
<div v-for="item in builds.boots" style="margin-left: 5px; margin-right: 5px">
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Core items -->
<ItemBox title="core">
<ItemTree style="margin: auto; width: fit-content" :tree="builds.tree" />
</ItemBox>
<!-- Boots -->
<ItemBox v-if="builds.bootsFirst <= 0.5" title="boots">
<div class="iv-items-container">
<div v-for="item in builds.boots.slice(0, 4)" style="margin-left: 5px; margin-right: 5px">
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Late game items -->
<ItemBox title="late game">
<div id="iv-late-game-container">
<div class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(0, 4)"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
/>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div> </div>
<!-- Boots first : when champion rush boots --> <div v-if="builds.lateGame.length > 4" class="iv-items-container">
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :bootsFirst="builds.bootsFirst"> <div
<div class="iv-items-container"> v-for="item in builds.lateGame.slice(4, 8)"
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.boots" > style="margin-left: 5px; margin-right: 5px"
<NuxtImg v-if="item.data != null && item.data != undefined" >
class="item-img" width="64" height="64" :alt="item.data.toString()" <NuxtImg
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" /> v-if="item.data != null && item.data != undefined"
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3> class="item-img"
</div> width="64"
</div> height="64"
</ItemBox> :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)"
<!-- Core items --> />
<ItemBox title="core"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
<ItemTree style="margin:auto; width: fit-content;" :tree="builds.tree" /> {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</ItemBox> </h3>
</div>
<!-- Boots --> </div>
<ItemBox v-if="builds.bootsFirst <= 0.5" title="boots"> </div>
<div class="iv-items-container"> </ItemBox>
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.boots.slice(0, 4)" > </div>
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3>
</div>
</div>
</ItemBox>
<!-- Late game items -->
<ItemBox title="late game">
<div id="iv-late-game-container">
<div class="iv-items-container">
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.lateGame.slice(0, 4)" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3>
</div>
</div>
<div class="iv-items-container" v-if="builds.lateGame.length > 4">
<div style="margin-left: 5px; margin-right: 5px;" v-for="item in builds.lateGame.slice(4, 8)" >
<NuxtImg v-if="item.data != null && item.data != undefined"
class="item-img" width="64" height="64" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data).iconPath)" />
<h3 style="width: fit-content; margin:auto; margin-bottom: 10px;">{{ (item.count/builds.tree.count * 100).toFixed(0) }}%</h3>
</div>
</div>
</div>
</ItemBox>
</div>
</template> </template>
<style> <style>
#iv-container { #iv-container {
display: flex; display: flex;
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
} }
.iv-items-container { .iv-items-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
margin:auto; margin: auto;
} }
.item-img { .item-img {
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
margin: 10px; margin: 10px;
} }
#iv-late-game-container { #iv-late-game-container {
display: flex; display: flex;
} }
@media only screen and (max-width: 1000px) { @media only screen and (max-width: 1000px) {
#iv-container { #iv-container {
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
} }
.iv-items-container { .iv-items-container {
flex-direction: row; flex-direction: row;
} }
.item-img { .item-img {
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
#iv-late-game-container { #iv-late-game-container {
flex-direction: column; flex-direction: column;
} }
} }
</style> </style>

View File

@@ -1,22 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ defineProps<{
championName?: string championName?: string
championLanes?: Array<LaneData> championLanes?: Array<LaneData>
tierlistList?: boolean tierlistList?: boolean
}>() }>()
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-lanes="championLanes" :champion-name="championName"
:tierlist-list="tierlistList" :champion-lanes="championLanes"
@state-change="(s, l) => emit('stateChange', s, l)"/> :tierlist-list="tierlistList"
<LazyNavBottomBar :champion-name="championName" @state-change="(s, l) => emit('stateChange', s, l)"
:champion-lanes="championLanes" />
:tierlist-list="tierlistList" <LazyNavBottomBar
@state-change="(s, l) => emit('stateChange', s, l)"/> :champion-name="championName"
</template> :champion-lanes="championLanes"
:tierlist-list="tierlistList"
@state-change="(s, l) => emit('stateChange', s, l)"
/>
</template>

View File

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

View File

@@ -1,135 +1,181 @@
<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
championLanes?: Array<LaneData> championLanes?: Array<LaneData>
tierlistList?: boolean tierlistList?: boolean
}>() }>()
const emit = defineEmits<{ 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
} }
</script> </script>
<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;"> "
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }} >
<h1 style="font-size: 2.4rem; padding-left: 20px">{{ championName }}</h1>
<NuxtImg
format="webp"
style="margin-left: 10px"
width="40"
height="40"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
/>
<h2
v-if="championName != null && championName != undefined && championName.length < 8"
style="margin-left: 5px; font-size: 1.8rem; font-weight: 200"
>
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
</h2> </h2>
</div> </div>
<h2 :class="'sidebar-link ' + (state == 'runes' && laneState == i ? 'sidebar-link-selected' : '')" <h2
@click="handleStateChange('runes', i)" :class="
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px;">Runes</h2> 'sidebar-link ' + (state == 'runes' && laneState == i ? 'sidebar-link-selected' : '')
<h2 :class="'sidebar-link ' + (state == 'items' && laneState == i ? 'sidebar-link-selected' : '')" "
@click="handleStateChange('items', i)" style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px;">Items</h2> @click="handleStateChange('runes', i)"
>
Runes
</h2>
<h2
:class="
'sidebar-link ' + (state == 'items' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
@click="handleStateChange('items', i)"
>
Items
</h2>
<h2 :class="'sidebar-link ' + (state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')" <h2
@click="handleStateChange('alternatives', i)" :class="
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px;">Alternatives</h2> 'sidebar-link ' +
(state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')
</div> "
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
<div v-if="tierlistList == true" style="margin-top: 30px;"> @click="handleStateChange('alternatives', i)"
<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"> Alternatives
<div :class="selected == pos ? 'sidebar-link-selected' : ''" </h2>
class="sidebar-link" style="padding-left: 35px; display: flex; align-items: center;">
<NuxtImg format="webp"
width="40" height="40"
:src="LANE_IMAGES[i]" :alt="POSITIONS_STR[i]" />
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px;">{{ POSITIONS_STR[i] }}</h3>
</div>
</NuxtLink>
</div>
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px;">
<h3 style="font-size: 23px; font-weight: 200;">
Patch {{ stats.patch }}
</h3>
<h3 style="font-size: 23px; font-weight: 200;">
{{ stats.count }} games
</h3>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px;">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of
Riot Games or anyone officially involved in producing or managing Riot Games properties.
Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc.
</h2>
</div>
</div> </div>
<div v-if="tierlistList == true" style="margin-top: 30px">
<h2 style="padding-left: 20px; font-size: 2.4rem; margin-bottom: 10px">Tierlist</h2>
<NuxtLink
v-for="(pos, i) in POSITIONS"
style="margin-top: 5px; margin-bottom: 5px"
:to="'/tierlist/' + pos"
>
<div
:class="selected == pos ? 'sidebar-link-selected' : ''"
class="sidebar-link"
style="padding-left: 35px; display: flex; align-items: center"
>
<NuxtImg
format="webp"
width="40"
height="40"
:src="LANE_IMAGES[i]"
:alt="POSITIONS_STR[i]"
/>
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px">
{{ POSITIONS_STR[i] }}
</h3>
</div>
</NuxtLink>
</div>
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px">
<h3 style="font-size: 23px; font-weight: 200">Patch {{ stats.patch }}</h3>
<h3 style="font-size: 23px; font-weight: 200">{{ stats.count }} games</h3>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot Games,
Inc.
</h2>
</div>
</div>
</template> </template>
<style> <style>
.sidebar-container { .sidebar-container {
background-color: #2B2826; background-color: #2b2826;
width: 300px; width: 300px;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
z-index: 10; z-index: 10;
} }
.sidebar-margin { .sidebar-margin {
width: 300px; width: 300px;
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-link { .sidebar-link {
user-select: none; user-select: none;
margin: 5px; margin: 5px;
padding-top: 5px; padding-top: 5px;
padding-bottom: 5px; padding-bottom: 5px;
border-radius: 8px; border-radius: 8px;
} }
.sidebar-link:hover { .sidebar-link:hover {
cursor: pointer; cursor: pointer;
background-color: var(--color-surface-darker); background-color: var(--color-surface-darker);
} }
.sidebar-link-selected { .sidebar-link-selected {
background-color: var(--color-surface); background-color: var(--color-surface);
} }
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
.sidebar-container { .sidebar-container {
display: none; display: none;
} }
.sidebar-margin { .sidebar-margin {
display: none; display: none;
} }
} }
</style> </style>

View File

@@ -1,116 +1,153 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
primaryStyleId: number primaryStyleId: number
secondaryStyleId: number secondaryStyleId: number
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)) {
secondaryStyle.value = style
}
} }
if (style.id == props.secondaryStyleId) {
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"
</div> style="margin: auto"
<div class="rune-slot" v-for="slot in primaryStyle.slots.slice(1, 4)"> :src="CDRAGON_BASE + mapPath(primaryStyle.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>
<div v-for="slot in primaryStyle.slots.slice(0, 1)" class="rune-slot">
<NuxtImg
v-for="perk in slot.perks"
width="48"
:class="
'rune-img rune-keystone ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')
"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
/>
</div>
<div v-for="slot in primaryStyle.slots.slice(1, 4)" class="rune-slot">
<NuxtImg
v-for="perk in slot.perks"
width="48"
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
/>
</div>
</div> </div>
<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> <div v-for="slot in secondaryStyle.slots.slice(1, 4)" class="rune-slot">
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)"> <NuxtImg
v-for="perk in slot.perks"
width="48"
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
/>
</div>
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)">
<img width="32" v-for="perk in slot.perks" :class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/> <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>
<style> <style>
.rune-holder { .rune-holder {
/* align-content: end; */ /* align-content: end; */
justify-content: center; justify-content: center;
} }
.rune-slot { .rune-slot {
width: calc(48*3px + 60px); width: calc(48 * 3px + 60px);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 40px; margin-top: 40px;
margin-bottom: 40px; margin-bottom: 40px;
} }
.mini { .mini {
margin: auto; margin: auto;
width: calc(32*3px + 60px); width: calc(32 * 3px + 60px);
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.rune-img { .rune-img {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
filter: grayscale(1); filter: grayscale(1);
border: 1px var(--color-on-surface) solid; border: 1px var(--color-on-surface) solid;
border-radius:50%; border-radius: 50%;
} }
.rune-keystone { .rune-keystone {
border: none; border: none;
} }
.rune-activated { .rune-activated {
filter: none; filter: none;
} }
.rune-spacer-bar { .rune-spacer-bar {
margin-left: 20px; margin-left: 20px;
margin-right: 20px; margin-right: 20px;
border: 1px var(--color-on-surface) solid; border: 1px var(--color-on-surface) solid;
} }
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
.rune-slot { .rune-slot {
width: calc(24*3px + 30px); width: calc(24 * 3px + 30px);
margin-top: 20px; margin-top: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.rune-img { .rune-img {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
.rune-style-img { .rune-style-img {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
.rune-spacer-bar { .rune-spacer-bar {
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
} }
} }
</style> </style>

View File

@@ -1,128 +1,155 @@
<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)
const primaryStyles : Ref<Array<PerkStyle>> = ref(Array(props.runes.length)) const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles : Ref<Array<PerkStyle>> = ref(Array(props.runes.length)) const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
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
} }
}
}
if(style.id == rune.secondaryStyle) {
secondaryStyles.value[props.runes.indexOf(rune)] = style
}
} }
}
if (style.id == rune.secondaryStyle) {
secondaryStyles.value[props.runes.indexOf(rune)] = style
}
} }
}
} }
refreshStylesKeystones() 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 v-for="(_, i) in runes" @click="runeSelect(i)"> />
<div :class="'rune-selector-entry ' + (i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')"> <div style="display: flex; margin-top: 20px; justify-content: center">
<div class="rs-styles-container"> <div v-for="(_, i) in runes" @click="runeSelect(i)">
<NuxtImg class="rs-style-img" v-if="primaryStyles[i] != null && primaryStyles[i] != undefined" <div
style="margin: auto;" :src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)" /> :class="
<NuxtImg class="rs-style-img" v-if="keystoneIds[i] != null && keystoneIds[i] != undefined" 'rune-selector-entry ' +
width="34" :src="CDRAGON_BASE + ( mapPath(perks.get(keystoneIds[i]).iconPath))"/> (i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')
<NuxtImg class="rs-style-img" v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined" "
style="margin: auto;" :src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)" /> >
</div> <div class="rs-styles-container">
<NuxtImg
v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)"
/>
<NuxtImg
v-if="keystoneIds[i] != null && keystoneIds[i] != undefined"
class="rs-style-img"
width="34"
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)"
/>
</div>
</div> </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>
</div>
</div> </div>
</div> </div>
</div>
</template> </template>
<style> <style>
.rune-selector-entry { .rune-selector-entry {
width: 200px; width: 200px;
height: 120px; height: 120px;
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
border-radius: 8%; border-radius: 8%;
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
} }
.rune-selector-entry:hover { .rune-selector-entry:hover {
cursor: pointer; cursor: pointer;
} }
.rune-selector-entry-selected { .rune-selector-entry-selected {
background-color: var(--color-surface-darker); background-color: var(--color-surface-darker);
} }
.rs-styles-container { .rs-styles-container {
display: flex; display: flex;
margin-top: 20px; margin-top: 20px;
} }
.rs-pickrate { .rs-pickrate {
text-align: center; text-align: center;
margin-top: -40px; margin-top: -40px;
padding-bottom: 40px; padding-bottom: 40px;
} }
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
.rune-selector-entry { .rune-selector-entry {
width: 100px; width: 100px;
height: 60px; height: 60px;
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
} }
.rs-styles-container { .rs-styles-container {
margin-top: 17px; margin-top: 17px;
} }
.rs-pickrate { .rs-pickrate {
margin-top: 5px; margin-top: 5px;
padding-bottom: 0px; padding-bottom: 0px;
} }
.rs-style-img { .rs-style-img {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
} }
</style> </style>

View File

@@ -1,38 +1,47 @@
<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> = []
const pickrates: Array<number> = [] 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)
images.push(CDRAGON_BASE + mapPath(champion.squarePortraitPath)) images.push(CDRAGON_BASE + mapPath(champion.squarePortraitPath))
backgroundColors.push(TIER_COLORS[colorIndex]) backgroundColors.push(TIER_COLORS[colorIndex])
count++ count++
} }
colorIndex++ colorIndex++
} }
const chartData = ref({ const chartData = ref({
labels: labels, labels: labels,
datasets: [ datasets: [
@@ -40,43 +49,45 @@ 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,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: {
x: { x: {
ticks: { ticks: {
callback: (() => "") callback: () => ''
} }
} }
}, },
plugins: { plugins: {
legend: { legend: {
display: false display: false
} }
} }
}) })
const chartPlugins = [{ const chartPlugins = [
id: "image-draw", {
afterDraw: ((chart: any) => { id: 'image-draw',
const ctx : CanvasRenderingContext2D = chart.ctx afterDraw: (chart: any) => {
var xAxis = chart.scales.x; const ctx: CanvasRenderingContext2D = chart.ctx
xAxis.ticks.forEach((value: any, index: number) => { const xAxis = chart.scales.x
var x = xAxis.getPixelForTick(index) xAxis.ticks.forEach((value: any, index: number) => {
var image = new Image() const x = xAxis.getPixelForTick(index)
image.src = images[index] const image = new Image()
ctx.drawImage(image, x - 14, xAxis.bottom - 28, 28, 28) image.src = images[index]
}) ctx.drawImage(image, x - 14, xAxis.bottom - 28, 28, 28)
}) })
}] }
}
]
</script> </script>
<template> <template>
<div> <div>
<Bar :data="chartData" :options="chartOptions" :plugins="chartPlugins" /> <Bar :data="chartData" :options="chartOptions" :plugins="chartPlugins" />
</div> </div>
</template> </template>

View File

@@ -1,73 +1,82 @@
<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>
</div> </div>
</template> </template>
<style> <style>
.tierlist-tier-container { .tierlist-tier-container {
width: 90%; width: 90%;
min-height: 122px; min-height: 122px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, 128px); grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px; grid-gap: 10px;
align-items: center; align-items: center;
margin: auto; margin: auto;
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.tierlist-tier-title { .tierlist-tier-title {
font-size: 3.3rem; font-size: 3.3rem;
margin-left: 20px; margin-left: 20px;
margin-right: 20px; margin-right: 20px;
margin-top: 40px; margin-top: 40px;
font-weight: 300; font-weight: 300;
} }
.champion-img-container { .champion-img-container {
overflow: hidden; width: 120px; height: 120px; overflow: hidden;
border: 1px solid var(--color-surface); width: 120px;
height: 120px;
border: 1px solid var(--color-surface);
} }
.champion-img-container:hover { .champion-img-container:hover {
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
} }
.champion-img { .champion-img {
width: 116px; width: 116px;
height: 116px; height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2); transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none; user-select: none;
} }
@media only screen and (max-width: 450px) { @media only screen and (max-width: 450px) {
.champion-img-container { .champion-img-container {
width: 80px; width: 80px;
height: 80px; height: 80px;
} }
.champion-img { .champion-img {
width: 76px; width: 76px;
height: 76px; height: 76px;
} }
.tierlist-tier-container { .tierlist-tier-container {
grid-template-columns: repeat(auto-fit, 80px); grid-template-columns: repeat(auto-fit, 80px);
min-height: 82px; min-height: 82px;
} }
} }
</style> </style>

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

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

View File

@@ -2,99 +2,112 @@
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', {
title: championData.value.name, title: championData.value.name,
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,
description: 'Build path and runes for ' + championData.value.name + ' on BuildPath' description: 'Build path and runes for ' + championData.value.name + ' on BuildPath'
}) })
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
laneState.value = newLane laneState.value = newLane
lane.value = championData.value.lanes[laneState.value] lane.value = championData.value.lanes[laneState.value]
} }
</script> </script>
<template> <template>
<Head> <Head>
<Title>{{ championData.name }}</Title> <Title>{{ championData.name }}</Title>
</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
Sorry, there is no data for this champion :( v-if="state == 'items' && championData.gameCount > 0"
</h2> style="margin: auto; margin-top: 40px"
</div> :builds="lane.builds!!"
/>
<ItemTree
v-if="state == 'alternatives' && championData.gameCount > 0"
style="margin: auto; margin-top: 40px; width: fit-content"
:tree="lane.builds!!.tree"
/>
<h2
v-if="championData.gameCount == 0"
style="margin: auto; margin-top: 20px; width: fit-content"
>
Sorry, there is no data for this champion :(
</h2>
</div> </div>
</div>
</template> </template>
<style> <style>
#alias-content-wrapper { #alias-content-wrapper {
display: flex; display: flex;
/* min-height: 100vh; */ /* min-height: 100vh; */
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
} }
#champion-content { #champion-content {
margin-top: 64px; margin-top: 64px;
margin-left: 39px; margin-left: 39px;
width: 100%; width: 100%;
} }
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
#champion-content { #champion-content {
margin: auto; margin: auto;
margin-top: 10px; margin-top: 10px;
} }
#champion-title { #champion-title {
margin:auto; margin: auto;
} }
} }
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
#alias-content-wrapper { #alias-content-wrapper {
flex-direction: column; flex-direction: column;
padding-bottom: 120px; padding-bottom: 120px;
margin-top: 20px; margin-top: 20px;
} }
} }
</style> </style>

View File

@@ -1,63 +1,60 @@
<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>
<Head> <Head>
<Title>Home</Title> <Title>Home</Title>
</Head> </Head>
<div class="index-main-content"> <div class="index-main-content">
<NavBar :tierlist-list="true" /> <NavBar :tierlist-list="true" />
<ChampionSelector class="index-champion-selector"/>
</div> <ChampionSelector class="index-champion-selector" />
</div>
</template> </template>
<style> <style>
.index-main-content { .index-main-content {
display: flex; display: flex;
margin-top: 50px; margin-top: 50px;
} }
.index-champion-selector { .index-champion-selector {
width: 80%; width: 80%;
} }
.index-tierlist-info-container { .index-tierlist-info-container {
margin: auto; margin: auto;
margin-top: 0px; margin-top: 0px;
width: fit-content; width: fit-content;
} }
#index-tierlists { #index-tierlists {
margin: auto; margin: auto;
margin-top: 10px; margin-top: 10px;
width: fit-content; width: fit-content;
} }
@media only screen and (max-width: 1240px) { @media only screen and (max-width: 1240px) {
.index-main-content { .index-main-content {
flex-direction: column; flex-direction: column;
margin-top: 5px; margin-top: 5px;
} }
.index-champion-selector { .index-champion-selector {
width: 100%; width: 100%;
} }
.index-tierlist-info-container { .index-tierlist-info-container {
display: flex; display: flex;
margin-bottom: 50px; margin-bottom: 50px;
} }
} }
@media only screen and (max-width: 500px) { @media only screen and (max-width: 500px) {
#index-statp { #index-statp {
display: none; display: none;
} }
.index-tierlist-info-container { .index-tierlist-info-container {
margin-bottom: 10px; margin-bottom: 10px;
margin-left: 10px; margin-left: 10px;
display: none; display: none;
} }
} }
</style> </style>

View File

@@ -1,105 +1,129 @@
<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>> } =
const infoMap : Map<string, ChampionData> = new Map() await useFetch('/api/champions')
for(let champion of championsLanes.value) { const infoMap: Map<string, ChampionData> = new Map()
infoMap.set(champion.alias, champion) for (const champion of championsLanes.value) {
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
} }
} }
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 }>
return scaledPickrate > min && scaledPickrate <= max ).filter(({ scaledPickrate: scaledPickrate }) => {
}) 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>
<Head> <Head>
<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
style="
margin-left: 0px;
margin-top: 20px;
display: flex;
margin-bottom: 30px;
align-items: center;
"
>
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300">Tierlist for</h1>
<NuxtImg
format="webp"
style="margin-left: 10px"
width="50"
height="50"
:src="LANE_IMAGES[lanePositionToIndex(lane)]"
/>
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300">
{{ POSITIONS_STR[lanePositionToIndex(lane)] }}
</h1>
</div>
<div style="margin-left: 0px; margin-top: 20px; display: flex; margin-bottom: 30px; align-items: center"> <TierlistTier v-for="tier in tiers" :title="tier.title" :tier="tier.data" />
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300;">Tierlist for</h1>
<NuxtImg format="webp" style="margin-left: 10px;"
width="50" height="50"
:src="LANE_IMAGES[lanePositionToIndex(lane)]" />
<h1 style="margin-left: 10px; font-size: 2.8rem; font-weight: 300;">{{ POSITIONS_STR[lanePositionToIndex(lane)] }}</h1>
</div>
<TierlistTier v-for="tier in tiers" :title="tier.title" :tier="tier.data" /> <TierlistChart id="chart" :data="tiers" />
<TierlistChart id="chart" :data="tiers" />
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
#chart { #chart {
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) {
#chart { #chart {
margin-left: 2px; margin-left: 2px;
margin-right: 12px; margin-right: 12px;
margin-bottom: 40px; margin-bottom: 40px;
margin-top: 40px; margin-top: 40px;
} }
#tierlist-container { #tierlist-container {
padding-bottom: 120px; padding-bottom: 120px;
} }
} }
</style> </style>

View File

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

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,28 +1,30 @@
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()
if(x.lanes != undefined && x.lanes != null) { .toArray()) as unknown as Array<ChampionData>
for(let lane of x.lanes) { data.map(x => {
delete lane.builds if (x.lanes != undefined && x.lanes != null) {
delete lane.runes for (const lane of x.lanes) {
} delete lane.builds
} delete lane.runes
}) }
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()
return data await client.close()
return data
}) })

View File

@@ -1,17 +1,20 @@
import { CDRAGON_BASE } from "~/utils/cdragon"; import { CDRAGON_BASE } from '~/utils/cdragon'
async function championRoutes() { 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'
let routes : Array<string> = [] )
for(let champion of championsData) { ).json()
routes.push("/champion/" + champion.alias.toLowerCase())
} const routes: Array<string> = []
return routes for (const champion of championsData) {
routes.push('/champion/' + champion.alias.toLowerCase())
}
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,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 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()
return {patch: latestPatch, count: gameCount} await client.close()
return { patch: latestPatch, count: gameCount }
}) })

View File

@@ -1,21 +1,25 @@
import { MongoClient } from 'mongodb' 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 (
uri = process.env.MONGO_URI process.env.MONGO_URI != undefined &&
} process.env.MONGO_URI != null &&
const client = new MongoClient(uri) process.env.MONGO_URI != ''
await client.connect() ) {
return client uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
} }
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

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

View File

@@ -1,35 +1,55 @@
const CDRAGON_BASE = "https://raw.communitydragon.org/latest/" const CDRAGON_BASE = 'https://raw.communitydragon.org/latest/'
/* Lanes */ /* 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')
function laneIndexToPosition(index : number) { const LANE_IMAGES_HOVER = Array(5)
switch(index) { .fill('')
case 0: return "top" .map((_, index) => '/img/lanes/icon-position-' + POSITIONS[index] + '-hover.png')
case 1: return "jungle" const LANE_IMAGES_SELECTED = Array(5)
case 2: return "middle" .fill('')
case 3: return "bottom" .map((_, index) => '/img/lanes/icon-position-' + POSITIONS[index] + '-blue.png')
case 4: return "utility" function laneIndexToPosition(index: number) {
} switch (index) {
return null case 0:
return 'top'
case 1:
return 'jungle'
case 2:
return 'middle'
case 3:
return 'bottom'
case 4:
return 'utility'
}
return null
} }
function lanePositionToIndex(position : string) { 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)
}; }
} }
/** /**
@@ -26,12 +29,12 @@ export function debounce<T extends (...args: any[]) => any>(func: T, wait: numbe
* @returns Parsed JSON or default value * @returns Parsed JSON or default value
*/ */
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()
} }
/** /**
@@ -60,14 +63,14 @@ export function capitalize(str: string): string {
* @returns Readable lane name * @returns Readable lane name
*/ */
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`
} }
/** /**
@@ -103,12 +106,12 @@ export function getRuneImageUrl(runeId: number): string {
* @returns Formatted string * @returns Formatted 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,336 +1,374 @@
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(
const list = await response.json() 'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json'
return list )
const list = await response.json()
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
toRemove.push(item) ) {
} const toRemove: Array<{ count: number }> = []
} for (const item of array) {
for(let tr of toRemove) { if (item.count / totalGames < percentage) {
array.splice(array.indexOf(tr), 1) toRemove.push(item)
} }
}
for (const tr of toRemove) {
array.splice(array.indexOf(tr), 1)
}
} }
type Rune = { type Rune = {
count: number count: number
primaryStyle: number primaryStyle: number
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
count: number count: number
winningMatches: number winningMatches: number
losingMatches: number losingMatches: number
winrate: number winrate: number
pickrate: number pickrate: number
runes: Array<Rune> runes: Array<Rune>
builds: Builds builds: Builds
} }
type ChampionData = { type ChampionData = {
champion: Champion champion: Champion
winningMatches: number winningMatches: number
losingMatches: number losingMatches: number
lanes: Array<LaneData> lanes: Array<LaneData>
} }
function handleParticipantRunes(participant, runes: Array<Rune>) { 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}; }
let addRunes = true; const gameRunes: Rune = {
for(let rune of runes) { count: 1,
if(rune.primaryStyle == gameRunes.primaryStyle primaryStyle: primaryStyle,
&& rune.secondaryStyle == gameRunes.secondaryStyle secondaryStyle: secondaryStyle,
&& sameArrays(rune.selections, gameRunes.selections)) { selections: 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)
} }
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;
}
let itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade
if(event.type == "ITEM_DESTROYED") {
if(event.itemId == 3867) {
let suppItem : number = itemInfo.to.find((x:number) =>
x == participant.item0
|| x == participant.item1
|| x == participant.item2
|| x == participant.item3
|| x == participant.item4
|| x == participant.item5
|| x == participant.item6 )
if(suppItem != undefined) {
const already = builds.suppItems.find((x) => x.data == suppItem)
if(already == undefined) builds.suppItems.push({count:1, data: suppItem})
else already.count += 1
}
}
}
if(event.type != "ITEM_PURCHASED") continue;
// Handle boots upgrades
if(itemInfo.requiredBuffCurrencyName == "Feats_NoxianBootPurchaseBuff"
|| itemInfo.requiredBuffCurrencyName == "Feats_SpecialQuestBootBuff") {
continue;
}
// Handle boots differently
if(itemInfo.categories.includes("Boots")){
if(itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if(items.length < 2) {
builds.bootsFirst += 1
}
// Add to boots
const already = builds.boots.find((x) => x.data == event.itemId)
if(already == undefined) builds.boots.push({count:1, data:event.itemId})
else already.count += 1
}
continue;
}
// Check if item should be included
if(itemInfo.categories.includes("Consumable")) continue;
if(itemInfo.categories.includes("Trinket")) continue;
// Ignore zephyr
if(event.itemId == 3172) continue;
// Ignore Cull as not-first item
if(event.itemId == 1083 && items.length >= 1) continue;
// Ignore non-final items, except when first item bought
if(itemInfo.to.length != 0 && items.length >= 1) continue;
items.push(event.itemId)
} }
continue
}
const itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade
if (event.type == 'ITEM_DESTROYED') {
if (event.itemId == 3867) {
const suppItem: number = itemInfo.to.find(
(x: number) =>
x == participant.item0 ||
x == participant.item1 ||
x == participant.item2 ||
x == participant.item3 ||
x == participant.item4 ||
x == participant.item5 ||
x == participant.item6
)
if (suppItem != undefined) {
const already = builds.suppItems.find(x => x.data == suppItem)
if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem })
else already.count += 1
}
}
}
if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if (
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
}
// Handle boots differently
if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if (items.length < 2) {
builds.bootsFirst += 1
}
// Add to boots
const already = builds.boots.find(x => x.data == event.itemId)
if (already == undefined) builds.boots.push({ count: 1, data: event.itemId })
else already.count += 1
}
continue
}
// Check if item should be included
if (itemInfo.categories.includes('Consumable')) continue
if (itemInfo.categories.includes('Trinket')) continue
// Ignore zephyr
if (event.itemId == 3172) continue
// Ignore Cull as not-first item
if (event.itemId == 1083 && items.length >= 1) continue
// Ignore non-final items, except when first item bought
if (itemInfo.to.length != 0 && items.length >= 1) continue
items.push(event.itemId)
} }
}
// Core items
treeMerge(builds.tree, items.slice(1, 4)) // Core items
treeMerge(builds.tree, items.slice(1, 4))
// Start items
if(items.length >= 1) { // Start items
const already = builds.start.find((x) => x.data == items[0]) if (items.length >= 1) {
if(already == undefined) builds.start.push({count:1, data:items[0]}) const already = builds.start.find(x => x.data == items[0])
else already.count += 1 if (already == undefined) builds.start.push({ count: 1, data: items[0] })
} else already.count += 1
}
// Late game items
for(let item of items.slice(3)) { // Late game items
const already = builds.lateGame.find((x) => x.data == item) for (const item of items.slice(3)) {
if(already == undefined) builds.lateGame.push({count:1, data:item}) const already = builds.lateGame.find(x => x.data == item)
else already.count += 1 if (already == undefined) builds.lateGame.push({ count: 1, data: item })
} 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,
else lane.count += 1 boots: [],
lateGame: [],
suppItems: []
}
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
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
} }
// Runes
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
}
} }
async function handleMatchList(client: MongoClient, patch: string, champions: Map<number, ChampionData>) { async function handleMatchList(
const database = client.db("matches"); client: MongoClient,
const matches = database.collection(patch) patch: string,
const allMatches = matches.find() champions: Map<number, ChampionData>
const totalMatches: number = await matches.countDocuments() ) {
const database = client.db('matches')
const matches = database.collection(patch)
const allMatches = matches.find()
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 + ' ... '
handleMatch(match, champions) )
} currentMatch += 1
handleMatch(match, champions)
return totalMatches }
return totalMatches
} }
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)
if(runes.length > 3)
runes.splice(3, runes.length - 3)
// Compute runes pickrate
for(let rune of runes)
rune.pickrate = rune.count / lane.count;
}
for(let lane of champion.lanes) { runes.sort((a, b) => b.count - a.count)
const builds = lane.builds if (runes.length > 3) runes.splice(3, runes.length - 3)
// Compute runes pickrate
for (const rune of runes) rune.pickrate = rune.count / lane.count
}
// Cut item tree branches to keep only 4 branches every time and with percentage threshold for (const lane of champion.lanes) {
builds.tree.count = lane.count; const builds = lane.builds
treeCutBranches(builds.tree, 4, 0.05)
treeSort(builds.tree)
// Cut item start, to only 4 and with percentage threshold // Cut item tree branches to keep only 4 branches every time and with percentage threshold
arrayRemovePercentage(builds.start, lane.count, 0.05) builds.tree.count = lane.count
builds.start.sort((a, b) => b.count - a.count) treeCutBranches(builds.tree, 4, 0.05)
if(builds.start.length > 4) treeSort(builds.tree)
builds.start.splice(4, builds.start.length - 4)
// Remove boots that are not within percentage threshold
arrayRemovePercentage(builds.boots, lane.count, 0.05)
builds.boots.sort((a, b) => b.count - a.count)
builds.bootsFirst /= lane.count
// Cut supp items below 2 and percentage threshold
arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
builds.suppItems.sort((a, b) => b.count - a.count)
if(builds.suppItems.length > 2)
builds.suppItems.splice(2, builds.suppItems.length - 2)
// Delete supp items if empty // Cut item start, to only 4 and with percentage threshold
if(builds.suppItems.length == 0) delete builds.suppItems arrayRemovePercentage(builds.start, lane.count, 0.05)
builds.start.sort((a, b) => b.count - a.count)
if (builds.start.length > 4) builds.start.splice(4, builds.start.length - 4)
builds.lateGame.sort((a, b) => b.count - a.count) // Remove boots that are not within percentage threshold
} arrayRemovePercentage(builds.boots, lane.count, 0.05)
builds.boots.sort((a, b) => b.count - a.count)
for(let lane of champion.lanes) { builds.bootsFirst /= lane.count
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
return {name: champion.champion.name, // Cut supp items below 2 and percentage threshold
alias: champion.champion.alias.toLowerCase(), arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
id: champion.champion.id, builds.suppItems.sort((a, b) => b.count - a.count)
lanes: champion.lanes, if (builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2)
winrate: champion.winningMatches / totalChampionMatches,
gameCount: totalChampionMatches, // Delete supp items if empty
pickrate: totalChampionMatches/totalMatches, if (builds.suppItems.length == 0) delete builds.suppItems
};
builds.lateGame.sort((a, b) => b.count - a.count)
}
for (const lane of champion.lanes) {
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
return {
name: champion.champion.name,
alias: champion.champion.alias.toLowerCase(),
id: champion.champion.id,
lanes: champion.lanes,
winrate: champion.winningMatches / totalChampionMatches,
gameCount: totalChampionMatches,
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(
const list = await response.json() 'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
return list.slice(1) )
const list = await response.json()
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,
losingMatches: 0, losingMatches: 0,
lanes: [] lanes: []
}) })
} }
// Loop through all matches to generate stats // Loop through all matches to generate stats
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 })
} }
// Create alias-index for better key-find // Create alias-index for better key-find
await collection.createIndex({alias:1}) await collection.createIndex({ alias: 1 })
} }
export default {makeChampionsStats} export default { makeChampionsStats }

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,207 +1,218 @@
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
console.log('MatchCollector - Hello !')
const client = await connectToDatabase()
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
console.log(
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch)
console.log('We already have ' + alreadySeenGameList.length + ' matches for this patch !')
console.log('Using RIOT_API_KEY: ' + api_key)
if (api_key != null && api_key != undefined && api_key != '') {
const challengerLeague = await fetchChallengerLeague()
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
const gameList = []
let i = 0
for (const challenger of challengerLeague.entries) {
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
const puuid = challenger.puuid
const challengerGameList = await summonerGameList(puuid, latestPatchTime)
for (const game of challengerGameList) {
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
}
}
i++
} }
// Original production mode console.log('Games: got ' + gameList.length + ' entries')
console.log("MatchCollector - Hello !"); i = 0
const client = await connectToDatabase(); for (const game of gameList) {
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client); console.log('Entry ' + i + '/' + gameList.length + ' ...')
console.log("Connected to database, latest patch " + latestPatch + " was epoch: " + latestPatchTime) const gameMatch = await match(game)
const gameTimeline = await matchTimeline(game)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch); gameMatch.timeline = gameTimeline
console.log("We already have " + alreadySeenGameList.length + " matches for this patch !") await saveMatch(client, gameMatch, latestPatch)
i++
console.log("Using RIOT_API_KEY: " + api_key)
if(api_key != null && api_key != undefined && api_key != "") {
const challengerLeague = await fetchChallengerLeague();
console.log("ChallengerLeague: got " + challengerLeague.entries.length + " entries");
const gameList = [];
let i = 0;
for(let challenger of challengerLeague.entries) {
console.log("Entry " + i + "/" + challengerLeague.entries.length + " ...")
const puuid = challenger.puuid;
const challengerGameList = await summonerGameList(puuid, latestPatchTime);
for(let game of challengerGameList) {
if(!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
}
}
i++;
}
console.log("Games: got " + gameList.length + " entries");
i = 0;
for(let game of gameList) {
console.log("Entry " + i + "/" + gameList.length + " ...")
const gameMatch = await match(game)
const gameTimeline = await matchTimeline(game)
gameMatch.timeline = gameTimeline
await saveMatch(client, gameMatch, latestPatch)
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()
} }
async function handleRateLimit(url : URL) : Promise<Response> { async function handleRateLimit(url: URL): Promise<Response> {
let response = await fetch(url) let response = await fetch(url)
if(response.status == 429) { if (response.status == 429) {
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000)) await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
response = await handleRateLimit(url) response = await handleRateLimit(url)
} }
return response return 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 (
uri = process.env.MONGO_URI process.env.MONGO_URI != undefined &&
} process.env.MONGO_URI != null &&
const client = new MongoClient(uri) process.env.MONGO_URI != ''
await client.connect() ) {
return client uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
} }
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)
const challengerLeague = await challengerLeagueResponse.json(); handleError(challengerLeagueResponse)
return challengerLeague;
const challengerLeague = await challengerLeagueResponse.json()
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)
} }
/** /**
* 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)`);
// Generate stats for each patch with data
for (const patch of patchCollections) {
console.log(`Generating stats for patch ${patch}...`);
await champion_stat.makeChampionsStats(client, patch);
console.log(`Stats generated for patch ${patch}`);
}
console.log("🎉 All stats generated successfully!");
console.log("🚀 Your development database is ready for frontend testing!");
} catch (error) {
console.error("❌ Error in development mode:", error);
throw error;
} finally {
await client.close();
} }
console.log(`Found ${patchCollections.length} match collection(s)`)
// Generate stats for each patch with data
for (const patch of patchCollections) {
console.log(`Generating stats for patch ${patch}...`)
await champion_stat.makeChampionsStats(client, patch)
console.log(`Stats generated for patch ${patch}`)
}
console.log('🎉 All stats generated successfully!')
console.log('🚀 Your development database is ready for frontend testing!')
} catch (error) {
console.error('❌ Error in development mode:', error)
throw error
} finally {
await client.close()
}
} }

View File

@@ -1,89 +1,91 @@
type ItemTree = { type ItemTree = {
data: any data: any
count: number count: number
children: Array<ItemTree> children: Array<ItemTree>
};
function treeInit() : ItemTree {
return {data:undefined, count:0, children:[]}
} }
function treeInit(): ItemTree {
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: [] }
} }
/* /*
* 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
}
} }
}
// If not found, add item node at this level // If not found, add item node at this level
if(next == null) { if (next == null) {
next = treeNode(item, count) next = treeNode(item, count)
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: [] })
} }
} }
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(
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1) (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)
}
// 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) {
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
} }
}
for (const tr of toRemove) {
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)
} }
} }
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)
} }
} }
export {ItemTree, treeMerge, treeInit, treeCutBranches, treeSort} export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort }

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,60 +1,61 @@
import { MongoClient } from "mongodb"; import { MongoClient } from 'mongodb'
main() main()
async function main() { 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)
const newDate = new Date() console.log('Latest patch is: ' + newPatch)
if(!(await compareLatestSavedPatch(client, newPatch, newDate))) {
downloadAssets()
}
await client.close() const newDate = new Date()
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
downloadAssets()
}
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 (
uri = process.env.MONGO_URI process.env.MONGO_URI != undefined &&
} process.env.MONGO_URI != null &&
const client = new MongoClient(uri) process.env.MONGO_URI != ''
await client.connect() ) {
return client uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
} }
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) {
console.log("No previous patch recorded in database.")
} else {
console.log("Latest patch in database is: " + latestPatch.patch)
}
if(latestPatch == null || latestPatch.patch != newPatch) {
await patches.insertOne({patch:newPatch, date:newDate})
return false
}
return true if (latestPatch == null) {
console.log('No previous patch recorded in database.')
} else {
console.log('Latest patch in database is: ' + latestPatch.patch)
}
if (latestPatch == null || latestPatch.patch != newPatch) {
await patches.insertOne({ patch: newPatch, date: newDate })
return false
}
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"]
},
} }
}