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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,336 +1,374 @@
function sameArrays(array1 : Array<any>, array2 : Array<any>) {
if(array1.length != array2.length) return false;
for(let e of array1) {
if(!array2.includes(e)) return false;
}
return true;
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
}
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 list = await response.json()
return list
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) {
toRemove.push(item)
}
}
for(let tr of toRemove) {
array.splice(array.indexOf(tr), 1)
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 (const tr of toRemove) {
array.splice(array.indexOf(tr), 1)
}
}
type Rune = {
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate?: number
};
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate?: number
}
type Builds = {
tree: ItemTree
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}>
tree: ItemTree
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 }>
}
type Champion = {
id: Number
name: String
alias: String
id: number
name: string
alias: string
}
type LaneData = {
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
runes: Array<Rune>
builds: Builds
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
runes: Array<Rune>
builds: Builds
}
type ChampionData = {
champion: Champion
winningMatches: number
losingMatches: number
lanes: Array<LaneData>
champion: Champion
winningMatches: number
losingMatches: number
lanes: Array<LaneData>
}
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) {
selections.push(perk.perk)
}
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
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) {
items.pop()
}
continue;
}
let itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade
if(event.type == "ITEM_DESTROYED") {
if(event.itemId == 3867) {
let suppItem : number = itemInfo.to.find((x:number) =>
x == participant.item0
|| x == participant.item1
|| x == participant.item2
|| x == participant.item3
|| x == participant.item4
|| x == participant.item5
|| x == participant.item6 )
if(suppItem != undefined) {
const already = builds.suppItems.find((x) => x.data == suppItem)
if(already == undefined) builds.suppItems.push({count:1, data: suppItem})
else already.count += 1
}
}
}
if(event.type != "ITEM_PURCHASED") continue;
// Handle boots upgrades
if(itemInfo.requiredBuffCurrencyName == "Feats_NoxianBootPurchaseBuff"
|| itemInfo.requiredBuffCurrencyName == "Feats_SpecialQuestBootBuff") {
continue;
}
// Handle boots differently
if(itemInfo.categories.includes("Boots")){
if(itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if(items.length < 2) {
builds.bootsFirst += 1
}
// Add to boots
const already = builds.boots.find((x) => x.data == event.itemId)
if(already == undefined) builds.boots.push({count:1, data:event.itemId})
else already.count += 1
}
continue;
}
// Check if item should be included
if(itemInfo.categories.includes("Consumable")) continue;
if(itemInfo.categories.includes("Trinket")) continue;
// Ignore zephyr
if(event.itemId == 3172) continue;
// Ignore Cull as not-first item
if(event.itemId == 1083 && items.length >= 1) continue;
// Ignore non-final items, except when first item bought
if(itemInfo.to.length != 0 && items.length >= 1) continue;
items.push(event.itemId)
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
}
const itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade
if (event.type == 'ITEM_DESTROYED') {
if (event.itemId == 3867) {
const suppItem: number = itemInfo.to.find(
(x: number) =>
x == participant.item0 ||
x == participant.item1 ||
x == participant.item2 ||
x == participant.item3 ||
x == participant.item4 ||
x == participant.item5 ||
x == participant.item6
)
if (suppItem != undefined) {
const already = builds.suppItems.find(x => x.data == suppItem)
if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem })
else already.count += 1
}
}
}
if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if (
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
}
// Handle boots differently
if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if (items.length < 2) {
builds.bootsFirst += 1
}
// Add to boots
const already = builds.boots.find(x => x.data == event.itemId)
if (already == undefined) builds.boots.push({ count: 1, data: event.itemId })
else already.count += 1
}
continue
}
// Check if item should be included
if (itemInfo.categories.includes('Consumable')) continue
if (itemInfo.categories.includes('Trinket')) continue
// Ignore zephyr
if (event.itemId == 3172) continue
// Ignore Cull as not-first item
if (event.itemId == 1083 && items.length >= 1) continue
// Ignore non-final items, except when first item bought
if (itemInfo.to.length != 0 && items.length >= 1) continue
items.push(event.itemId)
}
// Core items
treeMerge(builds.tree, items.slice(1, 4))
// 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]})
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})
else already.count += 1
}
}
// Core items
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] })
else already.count += 1
}
// Late game items
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) {
participantIndex += 1
const championId = participant.championId
const champion = champions.get(championId)
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)
}
else lane.count += 1
// 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)
} else lane.count += 1
// Winrate
if(participant.win) {
champion.winningMatches++;
lane.winningMatches++;
}
else {
champion.losingMatches++;
lane.losingMatches++;
}
// Runes
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
// Winrate
if (participant.win) {
champion.winningMatches++
lane.winningMatches++
} else {
champion.losingMatches++
lane.losingMatches++
}
// Runes
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
}
}
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()
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;
handleMatch(match, champions)
}
return totalMatches
let currentMatch = 0
for await (const match of allMatches) {
process.stdout.write(
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
)
currentMatch += 1
handleMatch(match, champions)
}
return totalMatches
}
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)
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) {
const runes = lane.runes
runes.sort((a, b) => b.count - a.count)
if(runes.length > 3)
runes.splice(3, runes.length - 3)
// Compute runes pickrate
for(let rune of runes)
rune.pickrate = rune.count / lane.count;
}
// Filter runes to keep 3 most played
for (const lane of champion.lanes) {
const runes = lane.runes
for(let lane of champion.lanes) {
const builds = lane.builds
runes.sort((a, b) => b.count - a.count)
if (runes.length > 3) runes.splice(3, runes.length - 3)
// Compute runes pickrate
for (const rune of runes) rune.pickrate = rune.count / lane.count
}
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
builds.tree.count = lane.count;
treeCutBranches(builds.tree, 4, 0.05)
treeSort(builds.tree)
for (const lane of champion.lanes) {
const builds = lane.builds
// 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)
// Remove boots that are not within percentage threshold
arrayRemovePercentage(builds.boots, lane.count, 0.05)
builds.boots.sort((a, b) => b.count - a.count)
builds.bootsFirst /= lane.count
// Cut supp items below 2 and percentage threshold
arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
builds.suppItems.sort((a, b) => b.count - a.count)
if(builds.suppItems.length > 2)
builds.suppItems.splice(2, builds.suppItems.length - 2)
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
builds.tree.count = lane.count
treeCutBranches(builds.tree, 4, 0.05)
treeSort(builds.tree)
// Delete supp items if empty
if(builds.suppItems.length == 0) delete builds.suppItems
// 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)
builds.lateGame.sort((a, b) => b.count - a.count)
}
// Remove boots that are not within percentage threshold
arrayRemovePercentage(builds.boots, lane.count, 0.05)
builds.boots.sort((a, b) => b.count - a.count)
for(let lane of champion.lanes) {
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
builds.bootsFirst /= lane.count
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,
};
// Cut supp items below 2 and percentage threshold
arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
builds.suppItems.sort((a, b) => b.count - a.count)
if (builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2)
// Delete supp items if empty
if (builds.suppItems.length == 0) delete builds.suppItems
builds.lateGame.sort((a, b) => b.count - a.count)
}
for (const lane of champion.lanes) {
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
return {
name: champion.champion.name,
alias: champion.champion.alias.toLowerCase(),
id: champion.champion.id,
lanes: champion.lanes,
winrate: champion.winningMatches / totalChampionMatches,
gameCount: totalChampionMatches,
pickrate: totalChampionMatches / totalMatches
}
}
async function championList() {
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)
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) {
itemDict.set(item.id, item)
}
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")
const list = await championList()
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) {
champions.set(champion.id, {
champion: {id: champion.id, name: champion.name, alias: champion.alias},
winningMatches: 0,
losingMatches: 0,
lanes: []
})
}
// Pre-generate list of champions
const champions: Map<number, ChampionData> = new Map()
for (const champion of list) {
champions.set(champion.id, {
champion: { id: champion.id, name: champion.name, alias: champion.alias },
winningMatches: 0,
losingMatches: 0,
lanes: []
})
}
// Loop through all matches to generate stats
const totalMatches = await handleMatchList(client, patch, champions)
// Loop through all matches to generate stats
const totalMatches = await handleMatchList(client, patch, champions)
// Finalize and save stats for every champion
const database = client.db("champions")
const collection = database.collection(patch)
for(let champion of list) {
const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches)
await collection.updateOne({id: champion.id}, {$set: championInfo}, { upsert: true })
}
// Finalize and save stats for every champion
const database = client.db('champions')
const collection = database.collection(patch)
for (const champion of list) {
const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches)
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
}
// Create alias-index for better key-find
await collection.createIndex({alias:1})
// Create alias-index for better key-find
await collection.createIndex({ alias: 1 })
}
export default {makeChampionsStats}
export default { makeChampionsStats }

View File

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

View File

@@ -1,207 +1,218 @@
const base = "https://euw1.api.riotgames.com"
const base = 'https://euw1.api.riotgames.com'
const api_key = process.env.RIOT_API_KEY
const 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;
// 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
}
// Original production mode
console.log('MatchCollector - Hello !')
const client = await connectToDatabase()
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
console.log(
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch)
console.log('We already have ' + alreadySeenGameList.length + ' matches for this patch !')
console.log('Using RIOT_API_KEY: ' + api_key)
if (api_key != null && api_key != undefined && api_key != '') {
const challengerLeague = await fetchChallengerLeague()
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
const gameList = []
let i = 0
for (const challenger of challengerLeague.entries) {
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
const puuid = challenger.puuid
const challengerGameList = await summonerGameList(puuid, latestPatchTime)
for (const game of challengerGameList) {
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
}
}
i++
}
// Original production mode
console.log("MatchCollector - Hello !");
const client = await connectToDatabase();
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client);
console.log("Connected to database, latest patch " + latestPatch + " was epoch: " + latestPatchTime)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch);
console.log("We already have " + alreadySeenGameList.length + " matches for this patch !")
console.log("Using RIOT_API_KEY: " + api_key)
if(api_key != null && api_key != undefined && api_key != "") {
const challengerLeague = await fetchChallengerLeague();
console.log("ChallengerLeague: got " + challengerLeague.entries.length + " entries");
const gameList = [];
let i = 0;
for(let challenger of challengerLeague.entries) {
console.log("Entry " + i + "/" + challengerLeague.entries.length + " ...")
const puuid = challenger.puuid;
const challengerGameList = await summonerGameList(puuid, latestPatchTime);
for(let game of challengerGameList) {
if(!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
}
}
i++;
}
console.log("Games: got " + gameList.length + " entries");
i = 0;
for(let game of gameList) {
console.log("Entry " + i + "/" + gameList.length + " ...")
const gameMatch = await match(game)
const gameTimeline = await matchTimeline(game)
gameMatch.timeline = gameTimeline
await saveMatch(client, gameMatch, latestPatch)
i++;
}
console.log('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++
}
}
console.log("Generating stats...");
await champion_stat.makeChampionsStats(client, latestPatch)
console.log('Generating stats...')
await champion_stat.makeChampionsStats(client, latestPatch)
console.log("All done. Closing client.");
await client.close()
console.log('All done. Closing client.')
await client.close()
}
async function handleRateLimit(url : URL) : Promise<Response> {
let response = await fetch(url)
if(response.status == 429) {
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
response = await handleRateLimit(url)
}
async function handleRateLimit(url: URL): Promise<Response> {
let response = await fetch(url)
if (response.status == 429) {
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
response = await handleRateLimit(url)
}
return 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 != "") {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
}
async function fetchLatestPatchDate(client) {
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)]
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 endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
const url = `${base}${endpoint}?api_key=${api_key}`
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));
handleError(challengerLeagueResponse)
const challengerLeagueResponse = await handleRateLimit(new URL(url))
const challengerLeague = await challengerLeagueResponse.json();
return challengerLeague;
handleError(challengerLeagueResponse)
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 url = `${base}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
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));
handleError(gameListResponse)
const gameList = await gameListResponse.json();
const gameListResponse = await handleRateLimit(new URL(url))
handleError(gameListResponse)
const gameList = await gameListResponse.json()
return gameList;
return gameList
}
async function match(matchId) {
const base = "https://europe.api.riotgames.com"
const endpoint = `/lol/match/v5/matches/${matchId}`
const url = `${base}${endpoint}?api_key=${api_key}`
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 matchResponse = await handleRateLimit(new URL(url))
handleError(matchResponse)
const match = await matchResponse.json()
return match;
return match
}
async function matchTimeline(matchId) {
const base = "https://europe.api.riotgames.com"
const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
const url = `${base}${endpoint}?api_key=${api_key}`
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 timelineResponse = await handleRateLimit(new URL(url))
handleError(timelineResponse)
const timeline = await timelineResponse.json()
return timeline
return timeline
}
async function alreadySeenGames(client, latestPatch) {
const database = client.db("matches")
const matches = database.collection(latestPatch)
const database = client.db('matches')
const matches = database.collection(latestPatch)
const alreadySeen = await matches.distinct("metadata.matchId")
return alreadySeen
const alreadySeen = await matches.distinct('metadata.matchId')
return alreadySeen
}
async function saveMatch(client, match, latestPatch) {
const database = client.db("matches")
const matches = database.collection(latestPatch)
await matches.insertOne(match)
const database = client.db('matches')
const matches = database.collection(latestPatch)
await matches.insertOne(match)
}
/**
* 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();
try {
const [latestPatch] = await fetchLatestPatchDate(client);
console.log(`Latest patch: ${latestPatch}`);
const client = await connectToDatabase()
try {
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);
// 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)
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.log(`Found ${patchCollections.length} match collection(s)`);
// Generate stats for each patch with data
for (const patch of patchCollections) {
console.log(`Generating stats for patch ${patch}...`);
await champion_stat.makeChampionsStats(client, patch);
console.log(`Stats generated for patch ${patch}`);
}
console.log("🎉 All stats generated successfully!");
console.log("🚀 Your development database is ready for frontend testing!");
} catch (error) {
console.error("❌ Error in development mode:", error);
throw error;
} finally {
await client.close();
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.log(`Found ${patchCollections.length} match collection(s)`)
// Generate stats for each patch with data
for (const patch of patchCollections) {
console.log(`Generating stats for patch ${patch}...`)
await champion_stat.makeChampionsStats(client, patch)
console.log(`Stats generated for patch ${patch}`)
}
console.log('🎉 All stats generated successfully!')
console.log('🚀 Your development database is ready for frontend testing!')
} catch (error) {
console.error('❌ Error in development mode:', error)
throw error
} finally {
await client.close()
}
}

View File

@@ -1,89 +1,91 @@
type ItemTree = {
data: any
count: number
children: Array<ItemTree>
};
function treeInit() : ItemTree {
return {data:undefined, count:0, children:[]}
data: any
count: number
children: Array<ItemTree>
}
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;
}
// Try to find an existing node in this tree level with same item
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) {
next = treeNode(item, count)
itemtree.children.push(next)
}
// If not found, add item node at this level
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) {
// 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: []})
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
}
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
// Remove branches that are above threshold count
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) {
toRemove.push(child)
}
}
for(let tr of toRemove) {
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
// Remove branches that are of too low usage
const toRemove: Array<ItemTree> = []
for (const child of itemtree.children) {
if (child.count / itemtree.count < thresholdPerc) {
toRemove.push(child)
}
}
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)
treeMergeTree(node, child)
}
for (const child of itemtree2.children) {
const node = nodeMerge(itemtree1, child)
treeMergeTree(node, child)
}
}
function treeSort(itemtree: ItemTree) {
itemtree.children.sort((a, b) => b.count - a.count)
itemtree.children.sort((a, b) => b.count - a.count)
for(let item of itemtree.children) {
treeSort(item)
}
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"]
},
"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,60 +1,61 @@
import { MongoClient } from "mongodb";
import { MongoClient } from 'mongodb'
main()
async function main() {
const client = await connectToDatabase()
const newPatch = await fetchLatestPatch()
console.log("Latest patch is: " + newPatch)
const client = await connectToDatabase()
const newPatch = await fetchLatestPatch()
const newDate = new Date()
if(!(await compareLatestSavedPatch(client, newPatch, newDate))) {
downloadAssets()
}
console.log('Latest patch is: ' + newPatch)
await client.close()
const newDate = new Date()
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 != "") {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
}
async function 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.")
} else {
console.log("Latest patch in database is: " + latestPatch.patch)
}
if(latestPatch == null || latestPatch.patch != newPatch) {
await patches.insertOne({patch:newPatch, date:newDate})
return false
}
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()
return true
if (latestPatch == null) {
console.log('No previous patch recorded in database.')
} else {
console.log('Latest patch in database is: ' + latestPatch.patch)
}
if (latestPatch == null || latestPatch.patch != newPatch) {
await patches.insertOne({ patch: newPatch, date: newDate })
return false
}
return true
}
async function downloadAssets() {
}
async function downloadAssets() {}

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,13 @@
"name": "patch_detector",
"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"]
},
"compilerOptions": {
"types": ["node"]
}
}