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
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:
needs: lint-and-format
runs-on: ubuntu-latest
steps:
- 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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,50 @@
<script setup lang="ts">
const props = defineProps<{
championId: number,
winrate: number,
pickrate: number,
championId: number
winrate: number
pickrate: number
gameCount: number
}>()
const winrate = ref((props.winrate * 100).toFixed(2))
watch(() => props.winrate, () => {winrate.value = (props.winrate * 100).toFixed(2)})
watch(
() => props.winrate,
() => {
winrate.value = (props.winrate * 100).toFixed(2)
}
)
const pickrate = ref((props.pickrate * 100).toFixed(2))
watch(() => props.pickrate, () => {pickrate.value = (props.pickrate * 100).toFixed(2)})
watch(
() => props.pickrate,
() => {
pickrate.value = (props.pickrate * 100).toFixed(2)
}
)
const { data: championData } : ChampionResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champions/" + props.championId + ".json")
const { data: championData }: ChampionResponse = await useFetch(
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champions/' +
props.championId +
'.json'
)
const championName = championData.value.name
const championDescription = championData.value.title
</script>
<template>
<div style="display: flex; width: fit-content;">
<div style="display: flex; width: fit-content">
<div class="champion-title-img-container">
<NuxtImg width="160" height="160" class="champion-title-img" :src="CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' + championId + '.png'"/>
<NuxtImg
width="160"
height="160"
class="champion-title-img"
:src="
CDRAGON_BASE +
'plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/' +
championId +
'.png'
"
/>
</div>
<div id="ct-info-container">

View File

@@ -1,23 +1,30 @@
<script setup lang="ts">
import { LANE_IMAGES, LANE_IMAGES_HOVER, LANE_IMAGES_SELECTED, POSITIONS_STR } from '~/utils/cdragon';
import {
LANE_IMAGES,
LANE_IMAGES_HOVER,
LANE_IMAGES_SELECTED,
POSITIONS_STR
} from '~/utils/cdragon'
const emit = defineEmits<{
filterChange: [value: number]
}>()
const laneImgs = Array(5).fill(ref("")).map((_, index) => ref(LANE_IMAGES[index]))
const laneImgs = Array(5)
.fill(ref(''))
.map((_, index) => ref(LANE_IMAGES[index]))
const laneFilter = ref(-1)
function selectLaneFilter(index: number) {
// Unselect previous filter
if(laneFilter.value != -1) {
if (laneFilter.value != -1) {
laneImgs[laneFilter.value].value = LANE_IMAGES[laneFilter.value]
// This is a deselection.
if(laneFilter.value == index) {
laneFilter.value = -1;
if (laneFilter.value == index) {
laneFilter.value = -1
emit('filterChange', laneFilter.value)
return;
return
}
}
@@ -28,25 +35,26 @@ function selectLaneFilter(index: number) {
}
function handleMouseOut(laneImg: Ref<string>, index: number) {
if(laneImg.value == LANE_IMAGES_HOVER[index])
laneImg.value = LANE_IMAGES[index]
if (laneImg.value == LANE_IMAGES_HOVER[index]) laneImg.value = LANE_IMAGES[index]
}
function handleHover(laneImg: Ref<string>, index: number) {
if(laneImg.value == LANE_IMAGES[index])
laneImg.value = LANE_IMAGES_HOVER[index]
if (laneImg.value == LANE_IMAGES[index]) laneImg.value = LANE_IMAGES_HOVER[index]
}
</script>
<template>
<div style="width: fit-content;">
<NuxtImg v-for="(laneImg, index) in laneImgs"
<div style="width: fit-content">
<NuxtImg
v-for="(laneImg, index) in laneImgs"
format="webp"
:alt="POSITIONS_STR[index]"
class="lane-img" :src="laneImg.value"
class="lane-img"
:src="laneImg.value"
@mouseout="handleMouseOut(laneImg, index)"
@mouseover="handleHover(laneImg, index)"
@click="selectLaneFilter(index)"/>
</div>
@click="selectLaneFilter(index)"
/>
</div>
</template>
<style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,28 @@
<script setup lang="ts">
defineProps<{
title: string
tier: Array<{champion: Champion, lane: LaneData}>
tier: Array<{ champion: Champion; lane: LaneData }>
}>()
</script>
<template>
<div style="display: flex;">
<div style="display: flex">
<h2 class="tierlist-tier-title">{{ title }}</h2>
<div class="tierlist-tier-container">
<NuxtLink v-for="{champion: champion} in tier" :to="'/champion/' + champion.alias.toLowerCase()">
<NuxtLink
v-for="{ champion: champion } in tier"
:to="'/champion/' + champion.alias.toLowerCase()"
>
<div class="champion-img-container">
<NuxtImg class="champion-img" :src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)" :alt="champion.name"/>
<NuxtImg
class="champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</div>
</div>
</div>
</template>
<style>
@@ -43,7 +50,9 @@ defineProps<{
}
.champion-img-container {
overflow: hidden; width: 120px; height: 120px;
overflow: hidden;
width: 120px;
height: 120px;
border: 1px solid var(--color-surface);
}
.champion-img-container:hover {

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
</script>
<script setup lang="ts"></script>
<template>
<Head>
@@ -10,29 +8,31 @@
<div class="about-main-content">
<NavBar :tierlist-list="true" />
<div style="width: fit-content; margin: auto;">
<div style="width: fit-content; margin: auto">
<Logo />
<div style="margin-top: 20px;">
<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 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 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 style="font-size: 1rem; font-weight: 200; margin-top: 25px; max-width: 800px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot
Games, Inc.
</h2>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -1,59 +1,72 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon';
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
const route = useRoute()
const lane = route.params.lane as string
const {data: championsData} : ChampionsResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json")
const { data: championsData }: ChampionsResponse = await useFetch(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const {data: championsLanes} : {data: Ref<Array<ChampionData>>} = await useFetch("/api/champions")
const infoMap : Map<string, ChampionData> = new Map()
for(let champion of championsLanes.value) {
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
await useFetch('/api/champions')
const infoMap: Map<string, ChampionData> = new Map()
for (const champion of championsLanes.value) {
infoMap.set(champion.alias, champion)
}
const champions = championsData.value.slice(1).filter((champion) => {
const championData : ChampionData | undefined = infoMap.get(champion.alias.toLowerCase())
if(championData == undefined) return false;
const champions = championsData.value.slice(1).filter(champion => {
const championData: ChampionData | undefined = infoMap.get(champion.alias.toLowerCase())
if (championData == undefined) return false
const lanes = championData.lanes
return lanes.reduce((acc : boolean, current : {data:string, count:number}) =>
acc || (current.data.toLowerCase() == lane.toLowerCase()), false)
return lanes.reduce(
(acc: boolean, current: { data: string; count: number }) =>
acc || current.data.toLowerCase() == lane.toLowerCase(),
false
)
})
const allChampions = champions.map((x) => {
const championData : ChampionData = infoMap.get(x.alias.toLowerCase())!!
const allChampions = champions
.map(x => {
const championData: ChampionData = infoMap.get(x.alias.toLowerCase())!
let currentLane = championData.lanes[0]
for(let championLane of championData.lanes) {
if(championLane.data.toLowerCase() == lane.toLowerCase()) {
for (const championLane of championData.lanes) {
if (championLane.data.toLowerCase() == lane.toLowerCase()) {
currentLane = championLane
break
}
}
return {lane: currentLane, champion: x}
}).sort((a, b) => b.lane.pickrate - a.lane.pickrate)
return { lane: currentLane, champion: x }
})
.sort((a, b) => b.lane.pickrate - a.lane.pickrate)
const p_min = Math.min(...allChampions.map((x) => x.lane.pickrate))
const p_max = Math.max(...allChampions.map((x) => x.lane.pickrate))
const p_min = Math.min(...allChampions.map(x => x.lane.pickrate))
const p_max = Math.max(...allChampions.map(x => x.lane.pickrate))
allChampions.map((x) => (x as {lane: LaneData, champion: Champion, scaledPickrate: number}).scaledPickrate = (x.lane.pickrate - p_min)/(p_max - p_min))
allChampions.map(
x =>
((x as { lane: LaneData; champion: Champion; scaledPickrate: number }).scaledPickrate =
(x.lane.pickrate - p_min) / (p_max - p_min))
)
allChampions.sort((a, b) => b.lane.pickrate - a.lane.pickrate)
function tierFromScaledPickrate(min: number, max: number) {
return (allChampions as Array<{lane: LaneData, champion: Champion, scaledPickrate: number}>)
.filter(({scaledPickrate: scaledPickrate}) => {
return (
allChampions as Array<{ lane: LaneData; champion: Champion; scaledPickrate: number }>
).filter(({ scaledPickrate: scaledPickrate }) => {
return scaledPickrate > min && scaledPickrate <= max
})
}
const tiers: Array<{title:string, data: Array<{lane: LaneData, champion: Champion}>}> = []
tiers.push({title: "S", data: tierFromScaledPickrate(0.9, 1)})
tiers.push({title: "A", data: tierFromScaledPickrate(0.7, 0.9)})
tiers.push({title: "B", data: tierFromScaledPickrate(0.5, 0.7)})
tiers.push({title: "C", data: tierFromScaledPickrate(0.3, 0.5)})
tiers.push({title: "D", data: tierFromScaledPickrate(0.1, 0.3)})
tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)})
const tiers: Array<{ title: string; data: Array<{ lane: LaneData; champion: Champion }> }> = []
tiers.push({ title: 'S', data: tierFromScaledPickrate(0.9, 1) })
tiers.push({ title: 'A', data: tierFromScaledPickrate(0.7, 0.9) })
tiers.push({ title: 'B', data: tierFromScaledPickrate(0.5, 0.7) })
tiers.push({ title: 'C', data: tierFromScaledPickrate(0.3, 0.5) })
tiers.push({ title: 'D', data: tierFromScaledPickrate(0.1, 0.3) })
tiers.push({ title: 'F', data: tierFromScaledPickrate(0, 0.1) })
</script>
<template>
@@ -61,26 +74,37 @@ tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)})
<Title>Tierlist for {{ POSITIONS_STR[lanePositionToIndex(lane)] }}</Title>
</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" />
<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 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>
<TierlistTier v-for="tier in tiers" :title="tier.title" :tier="tier.data" />
<TierlistChart id="chart" :data="tiers" />
</div>
</div>
</template>
<style scoped>
@@ -88,7 +112,7 @@ tiers.push({title: "F", data: tierFromScaledPickrate(0, 0.1)})
margin-left: 100px;
margin-right: 100px;
margin-bottom: 100px;
margin-top: 40px
margin-top: 40px;
}
@media only screen and (max-width: 450px) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,13 +31,13 @@ declare global {
iconPath: string
}
type PerkStylesResponse = {
data: Ref<{styles: Array<PerkStyle>}>
data: Ref<{ styles: Array<PerkStyle> }>
}
type PerkStyle = {
id: number
name: string
iconPath: string
slots: Array<{perks:Array<number>}>
slots: Array<{ perks: Array<number> }>
}
}

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,35 @@
function sameArrays(array1 : Array<any>, array2 : Array<any>) {
if(array1.length != array2.length) return false;
for(let e of array1) {
if(!array2.includes(e)) return false;
function sameArrays(array1: Array<any>, array2: Array<any>) {
if (array1.length != array2.length) return false
for (const e of array1) {
if (!array2.includes(e)) return false
}
return true;
return true
}
import { MongoClient } from "mongodb";
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from "./item_tree";
import { MongoClient } from 'mongodb'
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from './item_tree'
const itemDict = new Map()
async function itemList() {
const response = await fetch("https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json")
const response = await fetch(
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json'
)
const list = await response.json()
return list
}
function arrayRemovePercentage(array: Array<{count:number}>, totalGames:number, percentage: number) {
let toRemove : Array<{count:number}> = []
for(let item of array) {
if((item.count/totalGames) < percentage) {
function arrayRemovePercentage(
array: Array<{ count: number }>,
totalGames: number,
percentage: number
) {
const toRemove: Array<{ count: number }> = []
for (const item of array) {
if (item.count / totalGames < percentage) {
toRemove.push(item)
}
}
for(let tr of toRemove) {
for (const tr of toRemove) {
array.splice(array.indexOf(tr), 1)
}
}
@@ -34,19 +40,19 @@ type Rune = {
secondaryStyle: number
selections: Array<number>
pickrate?: number
};
}
type Builds = {
tree: ItemTree
start: Array<{data: number, count: number}>
start: Array<{ data: number; count: number }>
bootsFirst: number
boots: Array<{data: number, count: number}>
lateGame: Array<{data: number, count: number}>
suppItems?: Array<{data: number, count: number}>
boots: Array<{ data: number; count: number }>
lateGame: Array<{ data: number; count: number }>
suppItems?: Array<{ data: number; count: number }>
}
type Champion = {
id: Number
name: String
alias: String
id: number
name: string
alias: string
}
type LaneData = {
data: string
@@ -68,92 +74,105 @@ type ChampionData = {
function handleParticipantRunes(participant, runes: Array<Rune>) {
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
const selections : Array<number> = []
for(let style of participant.perks.styles) {
for(let perk of style.selections) {
const selections: Array<number> = []
for (const style of participant.perks.styles) {
for (const perk of style.selections) {
selections.push(perk.perk)
}
}
const gameRunes : Rune = {count:1, primaryStyle: primaryStyle, secondaryStyle: secondaryStyle, selections: selections};
let addRunes = true;
for(let rune of runes) {
if(rune.primaryStyle == gameRunes.primaryStyle
&& rune.secondaryStyle == gameRunes.secondaryStyle
&& sameArrays(rune.selections, gameRunes.selections)) {
rune.count++; addRunes = false; break;
const gameRunes: Rune = {
count: 1,
primaryStyle: primaryStyle,
secondaryStyle: secondaryStyle,
selections: selections
}
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) {
const items : Array<number> = []
for(let frame of timeline.info.frames) {
for(let event of frame.events) {
if(event.participantId != participantIndex) continue;
if(event.type == "ITEM_UNDO") {
if(items.length > 0 && items[items.length - 1] == event.beforeId) {
function handleMatchItems(timeline, participant: any, participantIndex: number, builds: Builds) {
const items: Array<number> = []
for (const frame of timeline.info.frames) {
for (const event of frame.events) {
if (event.participantId != participantIndex) continue
if (event.type == 'ITEM_UNDO') {
if (items.length > 0 && items[items.length - 1] == event.beforeId) {
items.pop()
}
continue;
continue
}
let itemInfo = itemDict.get(event.itemId)
const 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})
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;
if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if(itemInfo.requiredBuffCurrencyName == "Feats_NoxianBootPurchaseBuff"
|| itemInfo.requiredBuffCurrencyName == "Feats_SpecialQuestBootBuff") {
continue;
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)) {
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) {
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})
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;
continue
}
// Check if item should be included
if(itemInfo.categories.includes("Consumable")) continue;
if(itemInfo.categories.includes("Trinket")) continue;
if (itemInfo.categories.includes('Consumable')) continue
if (itemInfo.categories.includes('Trinket')) continue
// Ignore zephyr
if(event.itemId == 3172) continue;
if (event.itemId == 3172) continue
// Ignore Cull as not-first item
if(event.itemId == 1083 && items.length >= 1) continue;
if (event.itemId == 1083 && items.length >= 1) continue
// Ignore non-final items, except when first item bought
if(itemInfo.to.length != 0 && items.length >= 1) continue;
if (itemInfo.to.length != 0 && items.length >= 1) continue
items.push(event.itemId)
}
@@ -163,44 +182,58 @@ function handleMatchItems(timeline, participant: any, participantIndex : number,
treeMerge(builds.tree, items.slice(1, 4))
// Start items
if(items.length >= 1) {
const already = builds.start.find((x) => x.data == items[0])
if(already == undefined) builds.start.push({count:1, data:items[0]})
if (items.length >= 1) {
const already = builds.start.find(x => x.data == items[0])
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)) {
const already = builds.lateGame.find((x) => x.data == item)
if(already == undefined) builds.lateGame.push({count:1, data:item})
for (const item of items.slice(3)) {
const already = builds.lateGame.find(x => x.data == item)
if (already == undefined) builds.lateGame.push({ count: 1, data: item })
else already.count += 1
}
}
function handleMatch(match: any, champions : Map<number, ChampionData>) {
let participantIndex = 0;
for(let participant of match.info.participants) {
function handleMatch(match: any, champions: Map<number, ChampionData>) {
let participantIndex = 0
for (const participant of match.info.participants) {
participantIndex += 1
const championId = participant.championId
const champion = champions.get(championId)
// Lanes
let lane = champion.lanes.find((x) => x.data == participant.teamPosition)
if(lane == undefined) {
const builds : Builds = {tree:treeInit(), start: [], bootsFirst: 0, boots: [], lateGame: [], suppItems: []}
lane = {count:1, data: participant.teamPosition, runes:[], builds:builds, winningMatches: 0, losingMatches: 0, winrate: 0, pickrate: 0}
champion.lanes.push(lane)
let lane = champion.lanes.find(x => x.data == participant.teamPosition)
if (lane == undefined) {
const builds: Builds = {
tree: treeInit(),
start: [],
bootsFirst: 0,
boots: [],
lateGame: [],
suppItems: []
}
else lane.count += 1
lane = {
count: 1,
data: participant.teamPosition,
runes: [],
builds: builds,
winningMatches: 0,
losingMatches: 0,
winrate: 0,
pickrate: 0
}
champion.lanes.push(lane)
} else lane.count += 1
// Winrate
if(participant.win) {
champion.winningMatches++;
lane.winningMatches++;
}
else {
champion.losingMatches++;
lane.losingMatches++;
if (participant.win) {
champion.winningMatches++
lane.winningMatches++
} else {
champion.losingMatches++
lane.losingMatches++
}
// Runes
@@ -211,16 +244,22 @@ function handleMatch(match: any, champions : Map<number, ChampionData>) {
}
}
async function handleMatchList(client: MongoClient, patch: string, champions: Map<number, ChampionData>) {
const database = client.db("matches");
async function handleMatchList(
client: MongoClient,
patch: string,
champions: Map<number, ChampionData>
) {
const database = client.db('matches')
const matches = database.collection(patch)
const allMatches = matches.find()
const totalMatches: number = await matches.countDocuments()
let currentMatch = 0;
for await (let match of allMatches) {
process.stdout.write("\rComputing champion stats, game entry " + currentMatch + "/" + totalMatches + " ... ")
currentMatch += 1;
let currentMatch = 0
for await (const match of allMatches) {
process.stdout.write(
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
)
currentMatch += 1
handleMatch(match, champions)
}
@@ -228,36 +267,33 @@ async function handleMatchList(client: MongoClient, patch: string, champions: Ma
}
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)
champion.lanes.sort((a, b) => b.count - a.count)
// Filter runes to keep 3 most played
for(let lane of champion.lanes) {
for (const lane of champion.lanes) {
const runes = lane.runes
runes.sort((a, b) => b.count - a.count)
if(runes.length > 3)
runes.splice(3, runes.length - 3)
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 (const rune of runes) rune.pickrate = rune.count / lane.count
}
for(let lane of champion.lanes) {
for (const lane of champion.lanes) {
const builds = lane.builds
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
builds.tree.count = lane.count;
builds.tree.count = lane.count
treeCutBranches(builds.tree, 4, 0.05)
treeSort(builds.tree)
// Cut item start, to only 4 and with percentage threshold
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)
if (builds.start.length > 4) builds.start.splice(4, builds.start.length - 4)
// Remove boots that are not within percentage threshold
arrayRemovePercentage(builds.boots, lane.count, 0.05)
@@ -268,50 +304,52 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
// 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)
if (builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2)
// Delete supp items if empty
if(builds.suppItems.length == 0) delete builds.suppItems
if (builds.suppItems.length == 0) delete builds.suppItems
builds.lateGame.sort((a, b) => b.count - a.count)
}
for(let lane of champion.lanes) {
for (const lane of champion.lanes) {
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
return {name: champion.champion.name,
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,
};
pickrate: totalChampionMatches / totalMatches
}
}
async function championList() {
const response = await fetch("https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json");
const response = await fetch(
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const list = await response.json()
return list.slice(1)
}
async function makeChampionsStats(client: MongoClient, patch : string) {
var globalItems = await itemList()
for(let item of globalItems) {
async function makeChampionsStats(client: MongoClient, patch: string) {
const globalItems = await itemList()
for (const item of globalItems) {
itemDict.set(item.id, item)
}
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
const champions: Map<number, ChampionData> = new Map()
for(let champion of list) {
for (const champion of list) {
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,
losingMatches: 0,
lanes: []
@@ -322,15 +360,15 @@ async function makeChampionsStats(client: MongoClient, patch : string) {
const totalMatches = await handleMatchList(client, patch, champions)
// Finalize and save stats for every champion
const database = client.db("champions")
const database = client.db('champions')
const collection = database.collection(patch)
for(let champion of list) {
for (const champion of list) {
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
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,71 +1,73 @@
const base = "https://euw1.api.riotgames.com"
const base = 'https://euw1.api.riotgames.com'
const api_key = process.env.RIOT_API_KEY
const sleep_minutes = 12
import { MongoClient } from 'mongodb'
import champion_stat from "./champion_stat"
import champion_stat from './champion_stat'
main()
async function main() {
// Check if we're in development mode with pre-loaded data
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
console.log("MatchCollector - Development mode with pre-loaded data");
await runWithPreloadedData();
return;
console.log('MatchCollector - Development mode with pre-loaded data')
await runWithPreloadedData()
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)
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 !")
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");
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)) {
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++;
i++
}
console.log("Games: got " + gameList.length + " entries");
i = 0;
for(let game of gameList) {
console.log("Entry " + i + "/" + gameList.length + " ...")
console.log('Games: got ' + gameList.length + ' entries')
i = 0
for (const 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++;
i++
}
}
console.log("Generating stats...");
console.log('Generating stats...')
await champion_stat.makeChampionsStats(client, latestPatch)
console.log("All done. Closing client.");
console.log('All done. Closing client.')
await client.close()
}
async function handleRateLimit(url : URL) : Promise<Response> {
async function handleRateLimit(url: URL): Promise<Response> {
let response = await fetch(url)
if(response.status == 429) {
if (response.status == 429) {
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
response = await handleRateLimit(url)
}
@@ -73,17 +75,29 @@ async function handleRateLimit(url : URL) : Promise<Response> {
return response
}
function handleError(response : Response) {
if(!response.ok) {
console.log("Error during fetch(" + response.url + "): STATUS " + response.status + " (" + response.statusText + ")");
process.exit(1);
function handleError(response: Response) {
if (!response.ok) {
console.log(
'Error during fetch(' +
response.url +
'): STATUS ' +
response.status +
' (' +
response.statusText +
')'
)
process.exit(1)
}
}
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if(process.env.MONGO_URI != undefined && process.env.MONGO_URI != null && process.env.MONGO_URI != "") {
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
@@ -92,71 +106,71 @@ async function connectToDatabase() {
}
async function fetchLatestPatchDate(client) {
const database = client.db("patches");
const patches = database.collection("patches");
const latestPatch = await patches.find().limit(1).sort({date:-1}).next()
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return [latestPatch.patch, Math.floor(latestPatch.date.valueOf() / 1000)]
}
async function fetchChallengerLeague() {
const queue = "RANKED_SOLO_5x5"
const queue = 'RANKED_SOLO_5x5'
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
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();
return challengerLeague;
const challengerLeague = await challengerLeagueResponse.json()
return challengerLeague
}
async function summonerGameList(puuid, startTime) {
const base = "https://europe.api.riotgames.com"
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`;
const base = 'https://europe.api.riotgames.com'
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 gameListResponse = await handleRateLimit(new URL(url));
const gameListResponse = await handleRateLimit(new URL(url))
handleError(gameListResponse)
const gameList = await gameListResponse.json();
const gameList = await gameListResponse.json()
return gameList;
return gameList
}
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 url = `${base}${endpoint}?api_key=${api_key}`
const matchResponse = await handleRateLimit(new URL(url))
handleError(matchResponse)
const match = await matchResponse.json();
const match = await matchResponse.json()
return match;
return match
}
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 url = `${base}${endpoint}?api_key=${api_key}`
const timelineResponse = await handleRateLimit(new URL(url))
handleError(timelineResponse)
const timeline = await timelineResponse.json();
const timeline = await timelineResponse.json()
return timeline
}
async function alreadySeenGames(client, latestPatch) {
const database = client.db("matches")
const database = client.db('matches')
const matches = database.collection(latestPatch)
const alreadySeen = await matches.distinct("metadata.matchId")
const alreadySeen = await matches.distinct('metadata.matchId')
return alreadySeen
}
async function saveMatch(client, match, latestPatch) {
const database = client.db("matches")
const database = client.db('matches')
const matches = database.collection(latestPatch)
await matches.insertOne(match)
}
@@ -165,43 +179,40 @@ async function saveMatch(client, match, latestPatch) {
* Development mode function that generates stats from pre-loaded data
*/
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 {
const [latestPatch] = await fetchLatestPatchDate(client);
console.log(`Latest patch: ${latestPatch}`);
const [latestPatch] = await fetchLatestPatchDate(client)
console.log(`Latest patch: ${latestPatch}`)
// Check if we have matches for this patch
const matchesDb = client.db("matches");
const collections = await matchesDb.listCollections().toArray();
const patchCollections = collections
.map(c => c.name)
.filter(name => name === latestPatch);
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const patchCollections = collections.map(c => c.name).filter(name => name === latestPatch)
if (patchCollections.length === 0) {
console.error(`❌ No match data found for patch ${latestPatch}`);
console.log("💡 Please run the data import script first:");
console.log(" node dev/scripts/setup-db.js");
return;
console.error(`❌ No match data found for patch ${latestPatch}`)
console.log('💡 Please run the data import script first:')
console.log(' node dev/scripts/setup-db.js')
return
}
console.log(`Found ${patchCollections.length} match collection(s)`);
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(`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!");
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;
console.error('❌ Error in development mode:', error)
throw error
} finally {
await client.close();
await client.close()
}
}

View File

@@ -2,78 +2,80 @@ type ItemTree = {
data: any
count: number
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 {
return {data:data, count:count, children:[]}
function treeNode(data: number, count: number): ItemTree {
return { data: data, count: count, children: [] }
}
/*
* Merge a node with an item tree
*/
function nodeMerge(itemtree : ItemTree, node : ItemTree) {
const item = node.data;
const count = node.count;
let next : ItemTree | null = null;
* Merge a node with an item tree
*/
function nodeMerge(itemtree: ItemTree, node: ItemTree) {
const item = node.data
const count = node.count
let next: ItemTree | null = null
// Try to find an existing node in this tree level with same item
for(let node of itemtree.children) {
if(node.data == item) {
node.count += 1;
next = node;
break;
for (const node of itemtree.children) {
if (node.data == item) {
node.count += 1
next = node
break
}
}
// If not found, add item node at this level
if(next == null) {
if (next == null) {
next = treeNode(item, count)
itemtree.children.push(next)
}
return next;
return next
}
/*
* Merge a full build path with an existing item tree
*/
function treeMerge(itemtree : ItemTree, items : Array<number>) {
let current = itemtree;
* Merge a full build path with an existing item tree
*/
function treeMerge(itemtree: ItemTree, items: Array<number>) {
let current = itemtree
for(let item of items) {
current = nodeMerge(current, {data: item, count:1, children:[]})
for (const item of items) {
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
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: []})
while (itemtree.children.length > thresholdCount) {
const leastUsedBranch = itemtree.children.reduce(
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
{ data: undefined, count: +Infinity, children: [] }
)
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
}
// Remove branches that are of too low usage
let toRemove : Array<ItemTree> = []
for(let child of itemtree.children) {
if((child.count/itemtree.count) < thresholdPerc) {
const toRemove: Array<ItemTree> = []
for (const child of itemtree.children) {
if (child.count / itemtree.count < thresholdPerc) {
toRemove.push(child)
}
}
for(let tr of toRemove) {
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) {
for(let child of itemtree2.children) {
let node = nodeMerge(itemtree1, child)
for (const child of itemtree2.children) {
const node = nodeMerge(itemtree1, child)
treeMergeTree(node, child)
}
}
@@ -81,9 +83,9 @@ function treeMergeTree(itemtree1: ItemTree, itemtree2: ItemTree) {
function treeSort(itemtree: ItemTree) {
itemtree.children.sort((a, b) => b.count - a.count)
for(let item of itemtree.children) {
for (const item of itemtree.children) {
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",
"type": "module",
"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": "",
"license": "ISC",
@@ -13,7 +17,15 @@
"mongodb": "^6.10.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@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": {
"types": ["node"]
},
}
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { MongoClient } from "mongodb";
import { MongoClient } from 'mongodb'
main()
@@ -6,29 +6,32 @@ async function main() {
const client = await connectToDatabase()
const newPatch = await fetchLatestPatch()
console.log("Latest patch is: " + newPatch)
console.log('Latest patch is: ' + newPatch)
const newDate = new Date()
if(!(await compareLatestSavedPatch(client, newPatch, newDate))) {
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
downloadAssets()
}
await client.close()
}
async function fetchLatestPatch() {
const url = "https://ddragon.leagueoflegends.com/api/versions.json"
const patchDataResponse = await fetch(url);
const patchData = await patchDataResponse.json();
const patch : string = patchData[0];
return patch;
const url = 'https://ddragon.leagueoflegends.com/api/versions.json'
const patchDataResponse = await fetch(url)
const patchData = await patchDataResponse.json()
const patch: string = patchData[0]
return patch
}
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if(process.env.MONGO_URI != undefined && process.env.MONGO_URI != null && process.env.MONGO_URI != "") {
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
@@ -36,25 +39,23 @@ async function connectToDatabase() {
return client
}
async function compareLatestSavedPatch(client: MongoClient, newPatch : string, newDate : Date) {
const database = client.db("patches")
const patches = database.collection("patches")
const latestPatch = await patches.find().limit(1).sort({date:-1}).next()
async function compareLatestSavedPatch(client: MongoClient, newPatch: string, newDate: Date) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
if(latestPatch == null) {
console.log("No previous patch recorded in database.")
if (latestPatch == null) {
console.log('No previous patch recorded in database.')
} else {
console.log("Latest patch in database is: " + latestPatch.patch)
console.log('Latest patch in database is: ' + latestPatch.patch)
}
if(latestPatch == null || latestPatch.patch != newPatch) {
await patches.insertOne({patch:newPatch, date:newDate})
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",
"version": "1.0.0",
"main": "index.ts",
"type": "commonjs",
"type": "module",
"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": "",
"license": "ISC",
@@ -13,7 +17,15 @@
"mongodb": "^6.10.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@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": {
"types": ["node"]
},
}
}