├── packages └── core │ ├── src │ ├── utils │ │ └── fetch.ts │ ├── models │ │ ├── Team.ts │ │ ├── dataset │ │ │ ├── RankData.ts │ │ │ ├── ItemData.ts │ │ │ ├── SummonerSpellData.ts │ │ │ ├── ChampionMatchupData.ts │ │ │ ├── ChampionSynergyData.ts │ │ │ ├── ChampionDamageProfile.ts │ │ │ ├── PickData.ts │ │ │ ├── ChampionData.ts │ │ │ ├── RuneData.ts │ │ │ ├── ChampionRoleData.ts │ │ │ └── Dataset.ts │ │ ├── build │ │ │ ├── BuildEntity.ts │ │ │ └── BuildDataset.ts │ │ ├── user │ │ │ └── Config.ts │ │ └── Role.ts │ ├── rating │ │ └── ratings.ts │ ├── statistics │ │ └── stats.ts │ ├── risk │ │ └── risk-level.ts │ ├── damage-distribution │ │ └── damage-distribution.ts │ ├── stats.ts │ ├── builds │ │ ├── summoner-spell-analysis.ts │ │ ├── analysis.ts │ │ ├── skill-analysis.ts │ │ ├── rune-analysis.ts │ │ └── item-analysis.ts │ ├── draft │ │ ├── suggestions.ts │ │ ├── utils.ts │ │ └── extra-analysis.ts │ └── role │ │ └── role-predictor.ts │ ├── package.json │ └── tsconfig.json ├── apps ├── frontend │ ├── scripts │ │ ├── update-data.ts │ │ └── publish-tauri-update.ts │ ├── .gitignore │ ├── .prettierrc │ ├── public │ │ ├── riot.txt │ │ └── fonts │ │ │ ├── Oswald-VariableFont_wght.ttf │ │ │ └── OpenSans-VariableFont_wdth,wght.ttf │ ├── src │ │ ├── types │ │ │ ├── env.d.ts │ │ │ ├── tanstack-table.d.ts │ │ │ └── Lcu.ts │ │ ├── assets │ │ │ └── favicon.ico │ │ ├── utils │ │ │ ├── style.ts │ │ │ ├── strings.ts │ │ │ ├── mobile.ts │ │ │ ├── i18n.ts │ │ │ ├── analytics.ts │ │ │ ├── rating.ts │ │ │ ├── sites.ts │ │ │ └── toast.tsx │ │ ├── components │ │ │ ├── views │ │ │ │ └── builds │ │ │ │ │ ├── ItemSetStats.tsx │ │ │ │ │ ├── RecommendedBuild.tsx │ │ │ │ │ ├── BuildView.tsx │ │ │ │ │ ├── BootsStats.tsx │ │ │ │ │ ├── BuildsView.tsx │ │ │ │ │ ├── SummonerSpellsStats.tsx │ │ │ │ │ ├── ItemStats.tsx │ │ │ │ │ └── StarterItemStats.tsx │ │ │ ├── common │ │ │ │ ├── RoleCell.tsx │ │ │ │ ├── Panel.tsx │ │ │ │ ├── Popover.tsx │ │ │ │ ├── Badge.tsx │ │ │ │ ├── WinnerCell.tsx │ │ │ │ ├── ChampionCell.tsx │ │ │ │ ├── Button.tsx │ │ │ │ ├── Switch.tsx │ │ │ │ ├── RatingText.tsx │ │ │ │ ├── ViewTabs.tsx │ │ │ │ ├── Chart.tsx │ │ │ │ ├── ButtonGroup.tsx │ │ │ │ ├── Toast.tsx │ │ │ │ └── Dialog.tsx │ │ │ ├── icons │ │ │ │ ├── LoadingIcon.tsx │ │ │ │ ├── roles │ │ │ │ │ ├── AnyRoleIcon.tsx │ │ │ │ │ ├── BottomIcon.tsx │ │ │ │ │ ├── TopIcon.tsx │ │ │ │ │ ├── MidIcon.tsx │ │ │ │ │ ├── SupportIcon.tsx │ │ │ │ │ ├── JungleIcon.tsx │ │ │ │ │ └── RoleIcon.tsx │ │ │ │ └── ChampionIcon.tsx │ │ │ ├── draft │ │ │ │ ├── AnalyzeHoverToggle.tsx │ │ │ │ ├── FilterMenu.tsx │ │ │ │ ├── TeamSelector.tsx │ │ │ │ ├── DamageDistributionBar.tsx │ │ │ │ ├── TeamOptions.tsx │ │ │ │ ├── RoleFilter.tsx │ │ │ │ ├── TeamSidebar.tsx │ │ │ │ ├── LolClientStatusBadge.tsx │ │ │ │ └── Search.tsx │ │ │ ├── dialogs │ │ │ │ ├── UpdateDialog.tsx │ │ │ │ ├── WinrateDecompositionDialog.tsx │ │ │ │ └── FAQDialog.tsx │ │ │ ├── LanguageMenu.tsx │ │ │ ├── CountUp.tsx │ │ │ └── OptionsMenu.tsx │ │ ├── directives │ │ │ ├── click-outside.tsx │ │ │ └── tooltip.tsx │ │ ├── hooks │ │ │ ├── useMedia.ts │ │ │ └── createMediaQuery.ts │ │ ├── api │ │ │ └── lcu-api.ts │ │ ├── contexts │ │ │ ├── DraftViewContext.tsx │ │ │ ├── DraftFiltersContext.tsx │ │ │ ├── DatasetContext.tsx │ │ │ ├── DraftSuggestionsContext.tsx │ │ │ ├── ExtraDraftAnalysisContext.tsx │ │ │ ├── TooltipContext.tsx │ │ │ └── UserContext.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── src-tauri │ │ ├── build.rs │ │ ├── .gitignore │ │ ├── icons │ │ │ ├── 32x32.png │ │ │ ├── icon.icns │ │ │ ├── icon.ico │ │ │ ├── icon.png │ │ │ ├── 128x128.png │ │ │ ├── 128x128@2x.png │ │ │ ├── StoreLogo.png │ │ │ ├── Square30x30Logo.png │ │ │ ├── Square44x44Logo.png │ │ │ ├── Square71x71Logo.png │ │ │ ├── Square89x89Logo.png │ │ │ ├── Square107x107Logo.png │ │ │ ├── Square142x142Logo.png │ │ │ ├── Square150x150Logo.png │ │ │ ├── Square284x284Logo.png │ │ │ └── Square310x310Logo.png │ │ ├── Cargo.toml │ │ └── tauri.conf.json │ ├── postcss.config.js │ ├── .env.example │ ├── vite.config.ts │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── index.html │ ├── tailwind.config.js │ └── package.json └── dataset │ ├── .env.example │ ├── src │ ├── lolalytics │ │ ├── roles.ts │ │ ├── qwik-champion2.ts │ │ └── champion2.ts │ ├── utils.ts │ ├── storage │ │ ├── client.ts │ │ └── storage.ts │ └── riot.ts │ ├── tsconfig.json │ └── package.json ├── .gitignore ├── pnpm-workspace.yaml ├── turbo.json ├── tsconfig.json ├── package.json ├── README.md ├── .github └── workflows │ ├── dataset.yml │ └── release.yml └── scripts └── bump-version.ts /packages/core/src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/frontend/scripts/update-data.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | .turbo -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env -------------------------------------------------------------------------------- /apps/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/public/riot.txt: -------------------------------------------------------------------------------- 1 | a52929b1-75b3-43a5-a08c-f523f7f5c3a1 -------------------------------------------------------------------------------- /apps/frontend/src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const APP_VERSION: string; 2 | -------------------------------------------------------------------------------- /packages/core/src/models/Team.ts: -------------------------------------------------------------------------------- 1 | export type Team = "ally" | "opponent"; 2 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "./packages/*" 3 | - "./apps/*" 4 | -------------------------------------------------------------------------------- /apps/dataset/.env.example: -------------------------------------------------------------------------------- 1 | S3_ENDPOINT= 2 | S3_ACCESS_KEY_ID= 3 | S3_SECRET_ACCESS_KEY= 4 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /apps/frontend/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src/assets/favicon.ico -------------------------------------------------------------------------------- /packages/core/src/models/dataset/RankData.ts: -------------------------------------------------------------------------------- 1 | export interface RankData { 2 | wins: number; 3 | games: number; 4 | } 5 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /apps/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /packages/core/src/models/dataset/ItemData.ts: -------------------------------------------------------------------------------- 1 | export type ItemData = { 2 | id: number; 3 | name: string; 4 | gold: number; 5 | }; 6 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /apps/frontend/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /packages/core/src/models/dataset/SummonerSpellData.ts: -------------------------------------------------------------------------------- 1 | export type SummonerSpellData = { 2 | id: string; 3 | key: number; 4 | name: string; 5 | }; 6 | -------------------------------------------------------------------------------- /apps/frontend/public/fonts/Oswald-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/public/fonts/Oswald-VariableFont_wght.ttf -------------------------------------------------------------------------------- /apps/frontend/public/fonts/OpenSans-VariableFont_wdth,wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigovlugt/draftgap/HEAD/apps/frontend/public/fonts/OpenSans-VariableFont_wdth,wght.ttf -------------------------------------------------------------------------------- /packages/core/src/models/dataset/ChampionMatchupData.ts: -------------------------------------------------------------------------------- 1 | export interface ChampionMatchupData { 2 | championKey: string; 3 | games: number; 4 | wins: number; 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/ChampionSynergyData.ts: -------------------------------------------------------------------------------- 1 | export interface ChampionSynergyData { 2 | championKey: string; 3 | games: number; 4 | wins: number; 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/ChampionDamageProfile.ts: -------------------------------------------------------------------------------- 1 | export interface ChampionDamageProfile { 2 | magic: number; 3 | physical: number; 4 | true: number; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/PickData.ts: -------------------------------------------------------------------------------- 1 | import { ChampionData } from "./ChampionData"; 2 | import { Role } from "../Role"; 3 | 4 | export interface PickData extends ChampionData { 5 | probabilityByRole: Map; 6 | } 7 | -------------------------------------------------------------------------------- /apps/dataset/src/lolalytics/roles.ts: -------------------------------------------------------------------------------- 1 | export const LOLALYTICS_ROLES = [ 2 | "top", 3 | "jungle", 4 | "middle", 5 | "bottom", 6 | "support", 7 | ] as const; 8 | export type LolalyticsRole = typeof LOLALYTICS_ROLES[number]; 9 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | export const capitalize = (str: string) => 2 | str.charAt(0).toUpperCase() + str.slice(1); 3 | 4 | export const overflowEllipsis = (str: string, length: number) => 5 | str.length > length + 1 ? str.slice(0, length) + "..." : str; 6 | -------------------------------------------------------------------------------- /apps/frontend/.env.example: -------------------------------------------------------------------------------- 1 | S3_ENDPOINT= 2 | S3_ACCESS_KEY_ID= 3 | S3_SECRET_ACCESS_KEY= 4 | 5 | TAURI_PRIVATE_KEY= 6 | TAURI_KEY_PASSWORD= 7 | 8 | GIST_ID= 9 | GIST_TOKEN= 10 | 11 | REPOSITORY_NAME=draftgap 12 | REPOSITORY_OWNER=vigovlugt 13 | 14 | VITE_GA_TAG= 15 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "typecheck": {}, 5 | "build": { 6 | "dependsOn": ["typecheck"], 7 | "outputs": ["dist/**"] 8 | }, 9 | "lint": {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/frontend/src/types/tanstack-table.d.ts: -------------------------------------------------------------------------------- 1 | import "@tanstack/solid-table"; 2 | 3 | declare module "@tanstack/table-core" { 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | interface ColumnMeta { 6 | headerClass?: string; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/mobile.ts: -------------------------------------------------------------------------------- 1 | export const setupMobileVH = () => { 2 | const setProp = () => { 3 | const vh = window.innerHeight * 0.01; 4 | document.documentElement.style.setProperty("--vh", `${vh}px`); 5 | }; 6 | 7 | window.addEventListener("resize", () => setProp()); 8 | setProp(); 9 | }; 10 | -------------------------------------------------------------------------------- /apps/dataset/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2023", "DOM"], 4 | "module": "NodeNext", 5 | "target": "es2022", 6 | 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/ItemSetStats.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "solid-js"; 2 | import { Panel, PanelHeader } from "../../common/Panel"; 3 | 4 | export const ItemSetStats: Component = () => { 5 | return ( 6 | 7 | Builds 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2023"], 4 | "module": "Node16", 5 | "target": "es2022", 6 | 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@draftgap/core", 3 | "version": "2.13.0", 4 | "scripts": { 5 | "typecheck": "tsc --noEmit" 6 | }, 7 | "devDependencies": { 8 | "typescript": "^5.3.2" 9 | }, 10 | "dependencies": { 11 | "@tanstack/query-core": "^5.12.0" 12 | }, 13 | "packageManager": "pnpm@9.2.0" 14 | } -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2023", "DOM"], 4 | "module": "ESNext", 5 | "target": "es2022", 6 | 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/RecommendedBuild.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "solid-js"; 2 | import { Panel, PanelHeader } from "../../common/Panel"; 3 | 4 | export const RecommendedBuild: Component = () => { 5 | return ( 6 | 7 | Recommended Build 8 | TBD 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/RoleCell.tsx: -------------------------------------------------------------------------------- 1 | import { Role } from "@draftgap/core/src/models/Role"; 2 | import { RoleIcon } from "../icons/roles/RoleIcon"; 3 | 4 | export function RoleCell(props: { role: Role }) { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/src/directives/click-outside.tsx: -------------------------------------------------------------------------------- 1 | import { onCleanup } from "solid-js"; 2 | 3 | export default function clickOutside(el: HTMLElement, accessor: any) { 4 | const onClick = (e: MouseEvent) => 5 | !el.contains(e.target as any) && accessor()?.(); 6 | document.body.addEventListener("click", onClick); 7 | 8 | onCleanup(() => document.body.removeEventListener("click", onClick)); 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solidPlugin from "vite-plugin-solid"; 3 | 4 | export default defineConfig({ 5 | plugins: [solidPlugin()], 6 | define: { 7 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 8 | }, 9 | server: { 10 | port: 3000, 11 | }, 12 | build: { 13 | target: "esnext", 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/ChampionData.ts: -------------------------------------------------------------------------------- 1 | import { ChampionRoleData } from "./ChampionRoleData"; 2 | import { Role } from "../Role"; 3 | 4 | export interface ChampionData { 5 | id: string; 6 | key: string; 7 | name: string; 8 | i18n: Record< 9 | string, 10 | { 11 | name: string; 12 | } 13 | >; 14 | statsByRole: Record; 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/useMedia.ts: -------------------------------------------------------------------------------- 1 | import { createMediaQuery } from "./createMediaQuery"; 2 | 3 | export function useMedia() { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | const isDesktop = (window as any).__TAURI__ !== undefined; 6 | const isMobileLayout = createMediaQuery("(max-width: 1023px)"); 7 | 8 | return { 9 | isDesktop, 10 | isMobileLayout, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { ChampionData } from "@draftgap/core/src/models/dataset/ChampionData"; 2 | import { DraftGapConfig } from "@draftgap/core/src/models/user/Config"; 3 | 4 | export function championName(champion: ChampionData, config: DraftGapConfig) { 5 | if (config.language === "en_US") { 6 | return champion.name; 7 | } 8 | 9 | return champion.i18n[config.language]?.name ?? champion.name; 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "jsxImportSource": "solid-js", 11 | "types": ["vite/client", "@types/gtag.js", "./src/types/env"], 12 | "noEmit": true, 13 | "isolatedModules": true, 14 | "skipLibCheck": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/dataset/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@draftgap/dataset", 3 | "version": "2.13.0", 4 | "scripts": { 5 | "start": "tsx src/index.ts", 6 | "typecheck": "tsc --noEmit" 7 | }, 8 | "dependencies": { 9 | "@aws-sdk/client-s3": "^3.688.0", 10 | "@draftgap/core": "workspace:*", 11 | "@types/node": "^22.9.0", 12 | "dotenv": "^16.4.5", 13 | "tsx": "^4.19.2" 14 | }, 15 | "devDependencies": { 16 | "typescript": "^5.6.3" 17 | }, 18 | "packageManager": "pnpm@9.2.0" 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draftgap", 3 | "version": "2.13.0", 4 | "scripts": { 5 | "bump-version": "pnpm typecheck && tsx scripts/bump-version.ts", 6 | "typecheck": "turbo typecheck", 7 | "dev": "pnpm run --filter @draftgap/frontend dev", 8 | "tauri": "pnpm run --filter @draftgap/frontend tauri" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "^20.4.4", 12 | "tsx": "^3.12.7" 13 | }, 14 | "dependencies": { 15 | "turbo": "^2.5.3" 16 | }, 17 | "packageManager": "pnpm@9.2.0" 18 | } -------------------------------------------------------------------------------- /apps/frontend/src/hooks/createMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { createSignal, onCleanup } from "solid-js"; 2 | 3 | export const createMediaQuery = (query: string) => { 4 | const matchQuery = window.matchMedia(query); 5 | 6 | const [matches, setMatches] = createSignal(matchQuery.matches); 7 | 8 | const handler = (match: MediaQueryListEvent) => { 9 | setMatches(match.matches); 10 | }; 11 | 12 | matchQuery.addEventListener("change", handler); 13 | onCleanup(() => { 14 | matchQuery.removeEventListener("change", handler); 15 | }); 16 | 17 | return matches; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/core/src/rating/ratings.ts: -------------------------------------------------------------------------------- 1 | export function ratingToWinrate(d: number) { 2 | return 1 / (1 + Math.pow(10, -d / 400)); 3 | } 4 | 5 | // Get rating difference from winrate 6 | export function winrateToRating(w: number) { 7 | return -400 * Math.log10(1 / w - 1); 8 | } 9 | 10 | // Get winrate for w1 against w2 11 | export function getMatchupWinrate(w1: number, w2: number) { 12 | return ratingToWinrate(winrateToRating(w1) - winrateToRating(w2)); 13 | } 14 | 15 | export function getDuoWinrate(w1: number, w2: number) { 16 | return ratingToWinrate(winrateToRating(w1) + winrateToRating(w2)); 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DraftGap 2 | DraftGap.com is a site and desktop application which helps you pick and draft your League of Legends team. It suggests champions based on the meta, their matchups with every opponent champion and every ally duo. Its a unopinionated tool using only statistics to make suggestions. 3 | 4 | If you ever wondered what champion to pick, which is the best champion you could have picked, or if the game was truly lost in draft, DraftGap is for you. 5 | 6 | Find it at [draftgap.com](https://draftgap.com), or download the app, which integrates with the League client to automatically synchronize with the current draft in champ select. 7 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:solid/recommended" 10 | ], 11 | "overrides": [], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": ["@typescript-eslint", "solid"], 18 | "rules": { 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "@typescript-eslint/no-non-null-assertion": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js/jsx-runtime"; 2 | 3 | export function LoadingIcon(props: JSX.HTMLAttributes) { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/RuneData.ts: -------------------------------------------------------------------------------- 1 | export type RuneData = { 2 | id: number; 3 | key: string; 4 | name: string; 5 | icon: string; 6 | 7 | pathId: number; 8 | // 0: keystone, 1,2,3: minor runes in row 1,2 or 3 9 | slot: number; 10 | index: number; 11 | }; 12 | 13 | export type RunePathData = { 14 | id: number; 15 | key: string; 16 | name: string; 17 | icon: string; 18 | }; 19 | 20 | export type StatShardData = { 21 | id: number; 22 | key: string; 23 | name: string; 24 | 25 | positions: { 26 | // 0: offense, 1: flex, 2: defense 27 | slot: number; 28 | index: number; 29 | }[]; 30 | }; 31 | -------------------------------------------------------------------------------- /apps/dataset/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function bytesToHumanReadable(size: number) { 2 | const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); 3 | 4 | return `${parseFloat((size / Math.pow(1024, i)).toFixed(2))} ${ 5 | ["B", "kB", "MB", "GB", "TB"][i] 6 | }`; 7 | } 8 | 9 | export async function retry< 10 | T extends (...args: unknown[]) => Promise>> 11 | >(fn: T, retries = 5): Promise> { 12 | let error: unknown | undefined; 13 | for (let i = 0; i < retries; i++) { 14 | try { 15 | return await fn(); 16 | } catch (e) { 17 | console.log("Retrying, error:", e); 18 | error = e; 19 | } 20 | } 21 | 22 | throw error; 23 | } 24 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/AnyRoleIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js/jsx-runtime"; 2 | 3 | export default function BottomIcon(props: JSX.SvgSVGAttributes) { 4 | return ( 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ComponentProps, JSX, splitProps } from "solid-js"; 2 | import { cn } from "../../utils/style"; 3 | 4 | type Props = { 5 | children: JSX.Element; 6 | }; 7 | 8 | export const Panel: Component> = (props) => { 9 | const [local, other] = splitProps(props, ["children", "class"]); 10 | return ( 11 |
12 | {local.children} 13 |
14 | ); 15 | }; 16 | 17 | export const PanelHeader: Component = (props) => { 18 | return ( 19 |

20 | {props.children} 21 |

22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/dataset/src/storage/client.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from "@aws-sdk/client-s3"; 2 | import "dotenv/config"; 3 | 4 | if (!process.env.S3_ACCESS_KEY_ID) { 5 | throw new Error("S3_ACCESS_KEY_ID must be set"); 6 | } 7 | 8 | if (!process.env.S3_SECRET_ACCESS_KEY) { 9 | throw new Error("S3_SECRET_ACCESS_KEY must be set"); 10 | } 11 | 12 | if (!process.env.S3_ENDPOINT) { 13 | throw new Error("S3_ENDPOINT must be set"); 14 | } 15 | 16 | // Api token is stored in the environment variable 17 | export const client = new S3Client({ 18 | endpoint: process.env.S3_ENDPOINT, 19 | region: process.env.S3_REGION || "auto", 20 | credentials: { 21 | accessKeyId: process.env.S3_ACCESS_KEY_ID, 22 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/core/src/statistics/stats.ts: -------------------------------------------------------------------------------- 1 | export function calculateWilsonCI( 2 | wins: number, 3 | games: number, 4 | confidence: 0.95 | 0.99 | 0.999 | 0 5 | ) { 6 | if (confidence === 0) { 7 | return [wins / games, wins / games]; 8 | } 9 | 10 | const z = getZ(confidence); 11 | const p = wins / games; 12 | const n = games; 13 | const numerator = p + (z * z) / (2 * n); 14 | const denominator = 1 + (z * z) / n; 15 | const ci = 16 | (z * Math.sqrt((p * (1 - p) + (z * z) / (4 * n)) / n)) / denominator; 17 | return [numerator / denominator - ci, numerator / denominator + ci]; 18 | } 19 | 20 | function getZ(confidence: 0.95 | 0.99 | 0.999) { 21 | return { 22 | 0.95: 1.96, 23 | 0.99: 2.58, 24 | 0.999: 3.29, 25 | }[confidence]; 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/src/risk/risk-level.ts: -------------------------------------------------------------------------------- 1 | export const RiskLevel = [ 2 | "very-low", 3 | "low", 4 | "medium", 5 | "high", 6 | "very-high", 7 | ] as const; 8 | 9 | export type RiskLevel = (typeof RiskLevel)[number]; 10 | 11 | export const displayNameByRiskLevel: Record = { 12 | "very-low": "Very Low", 13 | low: "Low", 14 | medium: "Medium", 15 | high: "High", 16 | "very-high": "Very High", 17 | }; 18 | 19 | export const priorGamesByRiskLevel: Record = { 20 | "very-low": 3000, 21 | low: 2000, 22 | medium: 1000, 23 | high: 500, 24 | "very-high": 250, 25 | }; 26 | 27 | export const buildPriorGamesByRiskLevel: Record = { 28 | "very-low": 3000, 29 | low: 2000, 30 | medium: 1000, 31 | high: 750, 32 | "very-high": 500, 33 | }; 34 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/BottomIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js/jsx-runtime"; 2 | 3 | export default function BottomIcon(props: JSX.SvgSVGAttributes) { 4 | return ( 5 | 6 | 11 | 12 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/TopIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js/jsx-runtime"; 2 | 3 | export default function TopIcon(props: JSX.SvgSVGAttributes) { 4 | return ( 5 | 6 | 11 | 12 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/damage-distribution/damage-distribution.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "../models/Role"; 2 | import { Dataset } from "../models/dataset/Dataset"; 3 | 4 | export function getTeamDamageDistribution( 5 | dataset: Dataset, 6 | team: Map 7 | ) { 8 | const damageDistribution = { 9 | magic: 0, 10 | physical: 0, 11 | true: 0, 12 | }; 13 | 14 | for (const [role, championKey] of team.entries()) { 15 | const champion = dataset.championData[championKey]; 16 | const championRoleData = champion.statsByRole[role]; 17 | 18 | damageDistribution.magic += championRoleData.damageProfile.magic; 19 | damageDistribution.physical += championRoleData.damageProfile.physical; 20 | damageDistribution.true += championRoleData.damageProfile.true; 21 | } 22 | 23 | return damageDistribution; 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Popover.tsx: -------------------------------------------------------------------------------- 1 | import { Popover as PopoverPrimitive } from "@kobalte/core"; 2 | import { ComponentProps } from "solid-js"; 3 | import { cn } from "../../utils/style"; 4 | 5 | export const Popover = PopoverPrimitive.Root; 6 | 7 | export const PopoverTrigger = PopoverPrimitive.Trigger; 8 | 9 | type PopoverContentProps = ComponentProps; 10 | 11 | export function PopoverContent(props: PopoverContentProps) { 12 | return ( 13 | 14 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/models/build/BuildEntity.ts: -------------------------------------------------------------------------------- 1 | import { Skill, SkillOrder } from "./BuildDataset"; 2 | 3 | export type BuildEntity = 4 | | { 5 | type: "rune"; 6 | runeType: 7 | | "primary" 8 | | "secondary" 9 | | "shard-offense" 10 | | "shard-defense" 11 | | "shard-flex"; 12 | id: number; 13 | } 14 | | { 15 | type: "item"; 16 | itemType: number | "boots"; 17 | id: number; 18 | } 19 | | { 20 | type: "item"; 21 | itemType: "startingSets" | "sets"; 22 | id: string; 23 | } 24 | | { 25 | type: "summonerSpells"; 26 | id: string; 27 | } 28 | | ({ 29 | type: "skills"; 30 | } & ({ 31 | skillsType: "order" 32 | id: SkillOrder; 33 | } | { 34 | skillsType: "level"; 35 | level: number; 36 | id: Skill; 37 | })); 38 | 39 | export type BuildEntityType = BuildEntity["type"]; 40 | -------------------------------------------------------------------------------- /packages/core/src/models/user/Config.ts: -------------------------------------------------------------------------------- 1 | import { RiskLevel } from "../../risk/risk-level"; 2 | 3 | export type StatsSite = "op.gg" | "u.gg" | "lolalytics"; 4 | 5 | export const DraftTablePlacement = { 6 | Bottom: "bottom", 7 | Hidden: "hidden", 8 | InPlace: "in-place", 9 | } as const; 10 | type DraftTablePlacement = 11 | (typeof DraftTablePlacement)[keyof typeof DraftTablePlacement]; 12 | 13 | export type DraftGapConfig = { 14 | // DRAFT ANALYSIS 15 | ignoreChampionWinrates: boolean; 16 | riskLevel: RiskLevel; 17 | minGames: number; 18 | 19 | // DRAFT SUGGESTIONS 20 | showFavouritesAtTop: boolean; 21 | banPlacement: DraftTablePlacement; 22 | unownedPlacement: DraftTablePlacement; 23 | showAdvancedWinrates: boolean; 24 | language: string; 25 | 26 | // MISC 27 | defaultStatsSite: StatsSite; 28 | enableBetaFeatures: boolean; 29 | 30 | // LOL CLIENT 31 | disableLeagueClientIntegration: boolean; 32 | }; 33 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/MidIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js/jsx-runtime"; 2 | 3 | export default function MidIcon(props: JSX.SvgSVGAttributes) { 4 | return ( 5 | 6 | 11 | 15 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/SupportIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export default function SupportIcon( 4 | props: JSX.SvgSVGAttributes 5 | ) { 6 | return ( 7 | 8 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/models/Role.ts: -------------------------------------------------------------------------------- 1 | import { LolalyticsRole } from "../../../../apps/dataset/src/lolalytics/roles"; 2 | 3 | export const Role = { 4 | Top: 0, 5 | Jungle: 1, 6 | Middle: 2, 7 | Bottom: 3, 8 | Support: 4, 9 | } as const; 10 | 11 | export type Role = typeof ROLES[number]; 12 | 13 | export const ROLES = [0, 1, 2, 3, 4] as const; 14 | 15 | export const displayNameByRole = { 16 | [Role.Top]: "Top", 17 | [Role.Jungle]: "Jungle", 18 | [Role.Middle]: "Middle", 19 | [Role.Bottom]: "Bottom", 20 | [Role.Support]: "Support", 21 | }; 22 | 23 | export function getRoleFromString(role: LolalyticsRole): Role { 24 | switch (role) { 25 | case "top": 26 | return Role.Top; 27 | case "jungle": 28 | return Role.Jungle; 29 | case "middle": 30 | return Role.Middle; 31 | case "bottom": 32 | return Role.Bottom; 33 | case "support": 34 | return Role.Support; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/frontend/src/api/lcu-api.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api"; 2 | import { 3 | LolChampSelectChampSelectSession, 4 | LolChampSelectGridChampions, 5 | LolSummonerSummoner, 6 | } from "../types/Lcu"; 7 | 8 | export async function getChampSelectSession(): Promise { 9 | return (await invoke( 10 | "get_champ_select_session" 11 | )) as LolChampSelectChampSelectSession | null; 12 | } 13 | 14 | export async function getCurrentSummoner(): Promise { 15 | return (await invoke("get_current_summoner")) as LolSummonerSummoner | null; 16 | } 17 | 18 | export async function getGridChampions(): Promise { 19 | return (await invoke( 20 | "get_grid_champions" 21 | )) as LolChampSelectGridChampions | null; 22 | } 23 | 24 | export async function getPickableChampionIds(): Promise { 25 | return (await invoke("get_pickable_champion_ids")) as any; 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/src/stats.ts: -------------------------------------------------------------------------------- 1 | import { ratingToWinrate, winrateToRating } from "./rating/ratings"; 2 | 3 | export function addStats(...stats: { wins: number; games: number }[]) { 4 | let wins = 0; 5 | let games = 0; 6 | 7 | for (const stat of stats) { 8 | wins += stat.wins; 9 | games += stat.games; 10 | } 11 | 12 | return { 13 | wins, 14 | games, 15 | }; 16 | } 17 | 18 | export function multiplyStats( 19 | stats: { wins: number; games: number }, 20 | number: number 21 | ) { 22 | return { 23 | wins: stats.wins * number, 24 | games: stats.games * number, 25 | }; 26 | } 27 | 28 | export function divideStats( 29 | stats: { wins: number; games: number }, 30 | number: number 31 | ) { 32 | return { 33 | wins: stats.wins / number, 34 | games: stats.games / number, 35 | }; 36 | } 37 | 38 | export function averageStats(...stats: { wins: number; games: number }[]) { 39 | return divideStats(addStats(...stats), stats.length); 40 | } 41 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ComponentProps } from "solid-js"; 2 | import { JSX } from "solid-js/jsx-runtime"; 3 | import { Dynamic } from "solid-js/web"; 4 | 5 | type BadgeElement = "a" | "button" | "div" | "span"; 6 | type Props = { 7 | children: JSX.Element; 8 | theme: "primary" | "secondary"; 9 | as?: T; 10 | } & ComponentProps; 11 | 12 | export const Badge: Component = (props) => { 13 | const themeClass = () => 14 | ({ 15 | secondary: "bg-neutral-800 text-neutral-100", 16 | primary: "bg-neutral-100 text-neutral-800 font-bold", 17 | }[props.theme]); 18 | 19 | return ( 20 | 27 | {props.children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | license = "" 7 | repository = "" 8 | default-run = "app" 9 | edition = "2021" 10 | rust-version = "1.59" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.2.1", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.2.2", features = ["shell-open", "updater"] } 21 | regex = "1.7.0" 22 | color-eyre = "0.6.2" 23 | reqwest = { version = "0.11.13", features = ["serde_json", "json"] } 24 | 25 | [features] 26 | # by default Tauri runs in production mode 27 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 28 | default = ["custom-protocol"] 29 | # this feature is used for production builds where `devPath` points to the filesystem 30 | # DO NOT remove this 31 | custom-protocol = ["tauri/custom-protocol"] 32 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/AnalyzeHoverToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useDraftAnalysis } from "../../contexts/DraftAnalysisContext"; 2 | import { tooltip } from "../../directives/tooltip"; 3 | import { Icon } from "solid-heroicons"; 4 | import { eye, eyeSlash } from "solid-heroicons/solid"; 5 | import { cn } from "../../utils/style"; 6 | import { buttonVariants } from "../common/Button"; 7 | tooltip; 8 | 9 | export function AnalyzeHoverToggle() { 10 | const { analyzeHovers, setAnalyzeHovers } = useDraftAnalysis(); 11 | 12 | return ( 13 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/WinnerCell.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { arrowLeft, arrowRight } from "solid-heroicons/solid-mini"; 3 | import { Show } from "solid-js"; 4 | import { formatPercentage } from "../../utils/rating"; 5 | 6 | interface Props { 7 | winner: boolean; 8 | winrate?: number; 9 | } 10 | 11 | export function WinnerCell(props: Props) { 12 | return ( 13 |
14 | 23 | 24 | 25 | {formatPercentage(props.winrate!)} 26 | 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/analytics.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | dataLayer: any[]; 5 | } 6 | } 7 | 8 | export function setupAnalytics() { 9 | window.dataLayer = window.dataLayer || []; 10 | 11 | { 12 | // eslint-disable-next-line no-inner-declarations 13 | function gtag() { 14 | // eslint-disable-next-line prefer-rest-params 15 | window.dataLayer.push(arguments); 16 | } 17 | window.gtag = gtag; 18 | } 19 | 20 | const isProd = import.meta.env.PROD; 21 | if (!isProd) return; 22 | 23 | const tag = import.meta.env.VITE_GA_TAG; 24 | if (!tag) { 25 | console.error("Missing GA tag"); 26 | return; 27 | } 28 | 29 | gtag("js", new Date()); 30 | gtag("config", tag, { 31 | app_version: APP_VERSION, 32 | }); 33 | 34 | setInterval(() => { 35 | if (!document.hasFocus()) return; 36 | 37 | window.gtag("event", "heartbeat", { 38 | event_category: "heartbeat", 39 | non_interaction: true, 40 | }); 41 | }, 1000 * 15); 42 | } 43 | -------------------------------------------------------------------------------- /packages/core/src/builds/summoner-spell-analysis.ts: -------------------------------------------------------------------------------- 1 | import { AnalyzeDraftConfig } from "../draft/analysis"; 2 | import { 3 | PartialBuildDataset, 4 | FullBuildDataset, 5 | } from "../models/build/BuildDataset"; 6 | import { EntityAnalysisResult, analyzeEntity } from "./entity-analysis"; 7 | 8 | export type SummonerSpellsAnalysisResult = Record; 9 | 10 | export function analyzeSummonerSpells( 11 | partialBuildDataset: PartialBuildDataset, 12 | fullBuildDataset: FullBuildDataset, 13 | config: AnalyzeDraftConfig 14 | ): SummonerSpellsAnalysisResult { 15 | const summonerSpells = {} as Record; 16 | 17 | for (const spellSetId of Object.keys(partialBuildDataset.summonerSpells)) { 18 | summonerSpells[spellSetId] = analyzeEntity( 19 | partialBuildDataset, 20 | fullBuildDataset, 21 | config, 22 | getSummonerSpellStats, 23 | spellSetId 24 | ); 25 | } 26 | 27 | return summonerSpells; 28 | } 29 | 30 | function getSummonerSpellStats(data: PartialBuildDataset, id: string) { 31 | return data.summonerSpells[id] ?? { wins: 0, games: 0 }; 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/dataset.yml: -------------------------------------------------------------------------------- 1 | name: "Update dataset" 2 | on: 3 | schedule: 4 | - cron: "0 12 * * *" 5 | workflow_dispatch: 6 | 7 | defaults: 8 | run: 9 | working-directory: apps/dataset 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Install PNPM 19 | run: npm install -g pnpm 20 | 21 | - name: Sync node version and setup cache 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: "lts/*" 25 | cache: "pnpm" 26 | 27 | - name: Install app dependencies 28 | run: pnpm install 29 | 30 | # - name: Install Playwright Browsers 31 | # run: npx playwright install --with-deps 32 | 33 | - name: Update S3 dataset 34 | run: pnpm start 35 | env: 36 | S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} 37 | S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} 38 | S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} 39 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/rating.ts: -------------------------------------------------------------------------------- 1 | import { ratingToWinrate } from "@draftgap/core/src/rating/ratings"; 2 | 3 | export function formatRating(rating: number): string { 4 | return formatPercentage(ratingToWinrate(rating)); 5 | } 6 | 7 | export function formatPercentage( 8 | percentage: number, 9 | maxDecimals = 2, 10 | useMinDecimals = false 11 | ): string { 12 | const p = (percentage * 100).toFixed(maxDecimals).toString(); 13 | 14 | if (useMinDecimals) { 15 | return parseFloat(p).toString(); 16 | } 17 | 18 | return p; 19 | } 20 | 21 | export function getRatingClass(rating: number, noOkay = false) { 22 | const winrate = ratingToWinrate(rating); 23 | 24 | if (winrate < 0.45) { 25 | return "text-winrate-shiggo"; 26 | } else if (winrate < (noOkay ? 0.5 : 0.485)) { 27 | return "text-winrate-meh"; 28 | } else if (winrate < 0.515 && !noOkay) { 29 | return "text-winrate-okay"; 30 | } else if (winrate < 0.53) { 31 | return "text-winrate-good"; 32 | } else if (winrate < 0.55) { 33 | return "text-winrate-great"; 34 | } else if (isNaN(winrate)) { 35 | return "text-winrate-okay"; 36 | } 37 | 38 | return "text-winrate-volxd"; 39 | } 40 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/JungleIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js/jsx-runtime"; 2 | 3 | export default function JungleIcon(props: JSX.SvgSVGAttributes) { 4 | return ( 5 | 6 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ChampionCell.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from "solid-js"; 2 | import { overflowEllipsis } from "../../utils/strings"; 3 | import { ChampionIcon } from "../icons/ChampionIcon"; 4 | import { useDataset } from "../../contexts/DatasetContext"; 5 | import { useUser } from "../../contexts/UserContext"; 6 | import { championName } from "../../utils/i18n"; 7 | 8 | interface Props { 9 | championKey: string; 10 | nameMaxLength?: number; 11 | hideName?: boolean; 12 | } 13 | 14 | export default function ChampionCell(props: Props) { 15 | const { dataset } = useDataset(); 16 | const { config } = useUser(); 17 | 18 | const championData = () => dataset()!.championData[props.championKey]; 19 | const name = () => championName(championData(), config); 20 | 21 | return ( 22 |
23 | 24 | 25 | 26 | {props.nameMaxLength 27 | ? overflowEllipsis(name(), props.nameMaxLength) 28 | : name()} 29 | 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/DraftViewContext.tsx: -------------------------------------------------------------------------------- 1 | import { JSXElement, createContext, createSignal, useContext } from "solid-js"; 2 | 3 | type DraftView = 4 | | { 5 | type: "draft"; 6 | subType: "ally" | "opponent" | "draft"; 7 | } 8 | | { 9 | type: "analysis"; 10 | } 11 | | { 12 | type: "builds"; 13 | }; 14 | 15 | function createDraftViewContext() { 16 | const [currentDraftView, setCurrentDraftView] = createSignal({ 17 | type: "draft", 18 | subType: "ally", 19 | }); 20 | 21 | return { 22 | currentDraftView, 23 | setCurrentDraftView, 24 | }; 25 | } 26 | 27 | const DraftViewContext = 28 | createContext>(); 29 | 30 | export function DraftViewProvider(props: { children: JSXElement }) { 31 | const ctx = createDraftViewContext(); 32 | 33 | return ( 34 | 35 | {props.children} 36 | 37 | ); 38 | } 39 | 40 | export const useDraftView = () => { 41 | const useCtx = useContext(DraftViewContext); 42 | if (!useCtx) throw new Error("No DraftViewContext found"); 43 | 44 | return useCtx; 45 | }; 46 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Button.tsx: -------------------------------------------------------------------------------- 1 | import { VariantProps, cva } from "class-variance-authority"; 2 | import { JSX, splitProps } from "solid-js"; 3 | import { cn } from "../../utils/style"; 4 | 5 | export const buttonVariants = cva( 6 | "uppercase inline-flex items-center font-medium rounded-md transition ease-in-out duration-150", 7 | { 8 | variants: { 9 | variant: { 10 | primary: "bg-white text-primary hover:bg-neutral-200", 11 | secondary: 12 | "text-neutral-300 border border-neutral-700 bg-primary hover:bg-neutral-800", 13 | transparent: "hover:bg-white/10 disabled:pointer-events-none", 14 | }, 15 | size: { 16 | default: "px-4 py-1 text-xl", 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: "primary", 21 | size: "default", 22 | }, 23 | } 24 | ); 25 | 26 | type Props = JSX.HTMLAttributes & 27 | VariantProps; 28 | 29 | export function Button(props: Props) { 30 | const [local, other] = splitProps(props, ["children", "variant", "size"]); 31 | 32 | return ( 33 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | DraftGap - The Ultimate League of Legends Drafting Companion 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/sites.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "@draftgap/core/src/models/Role"; 2 | import { StatsSite } from "@draftgap/core/src/models/user/Config"; 3 | 4 | const UGG_ROLES = ["top", "jungle", "mid", "adc", "support"] as const; 5 | const OP_GG_ROLES = ["top", "jungle", "mid", "adc", "support"] as const; 6 | const LOLALYTICS_ROLES = [ 7 | "top", 8 | "jungle", 9 | "middle", 10 | "bottom", 11 | "support", 12 | ] as const; 13 | 14 | export const linkByStatsSite = ( 15 | statsSite: StatsSite, 16 | champion: string, 17 | role: Role 18 | ) => { 19 | champion = champion.toLowerCase(); 20 | if (champion === "monkeyking") champion = "wukong"; 21 | 22 | switch (statsSite) { 23 | case "lolalytics": 24 | return `https://lolalytics.com/lol/${champion}/build/?lane=${LOLALYTICS_ROLES[role]}`; 25 | case "u.gg": 26 | return `https://u.gg/lol/champions/${champion}/build/${UGG_ROLES[role]}`; 27 | case "op.gg": 28 | return `https://op.gg/champions/${champion}/${OP_GG_ROLES[role]}/build`; 29 | } 30 | }; 31 | 32 | export const displayNameByStatsSite = (statsSite: StatsSite) => { 33 | switch (statsSite) { 34 | case "lolalytics": 35 | return "LoLalytics"; 36 | case "u.gg": 37 | return "U.GG"; 38 | case "op.gg": 39 | return "OP.GG"; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/roles/RoleIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Match, Switch, splitProps } from "solid-js"; 2 | import { JSX } from "solid-js/jsx-runtime"; 3 | import { Role } from "@draftgap/core/src/models/Role"; 4 | import BottomIcon from "./BottomIcon"; 5 | import JungleIcon from "./JungleIcon"; 6 | import MidIcon from "./MidIcon"; 7 | import TopIcon from "./TopIcon"; 8 | import SupportIcon from "./SupportIcon"; 9 | 10 | type Props = Omit, "role"> & { 11 | role: Role; 12 | }; 13 | 14 | export const RoleIcon: Component = (_props) => { 15 | const [props, externalProps] = splitProps(_props, ["role"]); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Switch.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "solid-js"; 2 | import { Switch as SwitchPrimitives } from "@kobalte/core"; 3 | import { cn } from "../../utils/style"; 4 | 5 | export function Switch(props: ComponentProps) { 6 | return ( 7 | 17 | 18 | 19 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/DraftFiltersContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | JSXElement, 3 | batch, 4 | createContext, 5 | createSignal, 6 | useContext, 7 | } from "solid-js"; 8 | import { Role } from "@draftgap/core/src/models/Role"; 9 | 10 | export function createDraftFiltersContext() { 11 | const [search, setSearch] = createSignal(""); 12 | const [roleFilter, setRoleFilter] = createSignal(); 13 | 14 | const [favouriteFilter, setFavouriteFilter] = createSignal(false); 15 | 16 | function resetDraftFilters() { 17 | batch(() => { 18 | setSearch(""); 19 | setRoleFilter(undefined); 20 | setFavouriteFilter(false); 21 | }); 22 | } 23 | 24 | return { 25 | search, 26 | setSearch, 27 | roleFilter, 28 | setRoleFilter, 29 | favouriteFilter, 30 | setFavouriteFilter, 31 | resetDraftFilters, 32 | }; 33 | } 34 | 35 | export const DraftFiltersContext = 36 | createContext>(undefined); 37 | 38 | export function DraftFiltersProvider(props: { children: JSXElement }) { 39 | const ctx = createDraftFiltersContext(); 40 | 41 | return ( 42 | 43 | {props.children} 44 | 45 | ); 46 | } 47 | 48 | export function useDraftFilters() { 49 | const useCtx = useContext(DraftFiltersContext); 50 | if (!useCtx) throw new Error("No DraftFiltersContext found"); 51 | 52 | return useCtx; 53 | } 54 | -------------------------------------------------------------------------------- /packages/core/src/builds/analysis.ts: -------------------------------------------------------------------------------- 1 | import { AnalyzeDraftConfig } from "../draft/analysis"; 2 | import { 3 | FullBuildDataset, 4 | PartialBuildDataset, 5 | } from "../models/build/BuildDataset"; 6 | import { Dataset } from "../models/dataset/Dataset"; 7 | import { ItemsAnalysisResult, analyzeItems } from "./item-analysis"; 8 | import { 9 | RunesAnalysisResult as RunesAnalysisResult, 10 | analyzeRunes, 11 | } from "./rune-analysis"; 12 | import { SkillsAnalysisResult, analyzeSkills } from "./skill-analysis"; 13 | import { 14 | SummonerSpellsAnalysisResult, 15 | analyzeSummonerSpells, 16 | } from "./summoner-spell-analysis"; 17 | 18 | export type BuildAnalysisResult = { 19 | runes: RunesAnalysisResult; 20 | items: ItemsAnalysisResult; 21 | summonerSpells: SummonerSpellsAnalysisResult; 22 | skills: SkillsAnalysisResult; 23 | }; 24 | 25 | export function analyzeBuild( 26 | dataset: Dataset, 27 | fullDataset: Dataset, 28 | partialBuildDataset: PartialBuildDataset, 29 | fullBuildDatset: FullBuildDataset, 30 | config: AnalyzeDraftConfig 31 | ) { 32 | return { 33 | runes: analyzeRunes(partialBuildDataset, fullBuildDatset, config), 34 | items: analyzeItems(partialBuildDataset, fullBuildDatset, config), 35 | summonerSpells: analyzeSummonerSpells( 36 | partialBuildDataset, 37 | fullBuildDatset, 38 | config 39 | ), 40 | skills: analyzeSkills( 41 | partialBuildDataset, 42 | fullBuildDatset, 43 | config 44 | ), 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /apps/frontend/src/directives/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Placement } from "@popperjs/core"; 2 | import { Accessor, JSX, onCleanup, onMount } from "solid-js"; 3 | import { useTooltip } from "../contexts/TooltipContext"; 4 | 5 | type HelpPopoverParams = { 6 | content: JSX.Element; 7 | placement?: Placement; 8 | delay?: number; 9 | }; 10 | 11 | export function tooltip( 12 | el: HTMLElement, 13 | accessor: Accessor 14 | ) { 15 | const { 16 | setPopoverContent, 17 | setPopoverPlacement, 18 | setPopoverTarget, 19 | setPopoverVisible, 20 | } = useTooltip(); 21 | 22 | let timeout: NodeJS.Timeout | undefined; 23 | 24 | const onHover = (e: MouseEvent) => { 25 | const { content, placement, delay } = accessor(); 26 | const target = e.target as HTMLElement; 27 | 28 | timeout = setTimeout(() => { 29 | setPopoverContent(content); 30 | setPopoverPlacement(placement ?? "top"); 31 | setPopoverTarget(target); 32 | setPopoverVisible(true); 33 | }, delay ?? 300); 34 | }; 35 | 36 | const onHoverLeave = () => { 37 | setPopoverVisible(false); 38 | clearTimeout(timeout); 39 | }; 40 | 41 | onMount(() => { 42 | el.addEventListener("mouseleave", onHoverLeave); 43 | el.addEventListener("mouseenter", onHover); 44 | }); 45 | 46 | onCleanup(() => { 47 | el.removeEventListener("mouseleave", onHoverLeave); 48 | el.removeEventListener("mouseenter", onHover); 49 | clearTimeout(timeout); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/ChampionRoleData.ts: -------------------------------------------------------------------------------- 1 | import { ChampionDamageProfile } from "./ChampionDamageProfile"; 2 | import { ChampionMatchupData } from "./ChampionMatchupData"; 3 | import { ChampionSynergyData } from "./ChampionSynergyData"; 4 | import { Role } from "../Role"; 5 | 6 | export interface ChampionRoleData { 7 | games: number; 8 | wins: number; 9 | matchup: Record>; 10 | synergy: Record>; 11 | damageProfile: ChampionDamageProfile; 12 | statsByTime: { 13 | wins: number; 14 | games: number; 15 | }[]; 16 | } 17 | 18 | export function defaultChampionRoleData(): ChampionRoleData { 19 | return { 20 | games: 0, 21 | wins: 0, 22 | matchup: [0, 1, 2, 3, 4].reduce( 23 | (acc, role) => ({ ...acc, [role]: {} }), 24 | {} 25 | ) as ChampionRoleData["matchup"], 26 | synergy: [0, 1, 2, 3, 4].reduce( 27 | (acc, role) => ({ ...acc, [role]: {} }), 28 | {} 29 | ) as ChampionRoleData["synergy"], 30 | damageProfile: { 31 | magic: 0, 32 | physical: 0, 33 | true: 0, 34 | }, 35 | statsByTime: Array.from({ length: 7 }, () => ({ 36 | wins: 0, 37 | games: 0, 38 | })), 39 | }; 40 | } 41 | 42 | export function deleteChampionRoleDataMatchupSynergyData( 43 | data: ChampionRoleData 44 | ) { 45 | data.matchup = {} as ChampionRoleData["matchup"]; 46 | data.synergy = {} as ChampionRoleData["synergy"]; 47 | } 48 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/RatingText.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Show } from "solid-js"; 2 | import { formatRating, getRatingClass } from "../../utils/rating"; 3 | import { Icon } from "solid-heroicons"; 4 | import { exclamationTriangle } from "solid-heroicons/solid-mini"; 5 | import { tooltip } from "../../directives/tooltip"; 6 | tooltip; 7 | 8 | type Props = { 9 | rating: number; 10 | games?: number; 11 | }; 12 | 13 | export const RatingText: Component = (props) => { 14 | return ( 15 | 21 | {formatRating(props.rating)} 22 | 23 |
34 | 38 |
39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/frontend/src/components/icons/ChampionIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, splitProps } from "solid-js"; 2 | import { useDataset } from "../../contexts/DatasetContext"; 3 | import { cn } from "../../utils/style"; 4 | 5 | export function ChampionIcon( 6 | props: { 7 | championKey: string; 8 | imgClass?: string; 9 | size: number; 10 | } & JSX.HTMLAttributes 11 | ) { 12 | const [, other] = splitProps(props, ["championKey", "imgClass", "size"]); 13 | const { dataset } = useDataset(); 14 | 15 | return ( 16 |
24 | {dataset()!.championData[props.championKey].name} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ViewTabs.tsx: -------------------------------------------------------------------------------- 1 | import { For } from "solid-js"; 2 | import { cn } from "../../utils/style"; 3 | 4 | type Props = { 5 | tabs: readonly { 6 | value: T; 7 | label: string; 8 | }[]; 9 | selected: T; 10 | onChange: (tab: T) => void; 11 | class?: string; 12 | equals?: (a: T, b: T) => boolean; 13 | }; 14 | 15 | export const ViewTabs = (props: Props) => { 16 | return ( 17 |
23 | 24 | {(tab) => ( 25 | 42 | )} 43 | 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/FilterMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { funnel } from "solid-heroicons/solid"; 3 | import { ButtonGroup, ButtonGroupOption } from "../common/ButtonGroup"; 4 | import { Popover, PopoverContent, PopoverTrigger } from "../common/Popover"; 5 | import { useUser } from "../../contexts/UserContext"; 6 | import { buttonVariants } from "../common/Button"; 7 | import { cn } from "../../utils/style"; 8 | 9 | export function FilterMenu() { 10 | const { config, setConfig } = useUser(); 11 | 12 | const minGameCountOptions: ButtonGroupOption[] = [ 13 | 500, 1000, 2500, 5000, 14 | ].map((n) => ({ 15 | value: n, 16 | label: n.toString(), 17 | })); 18 | 19 | return ( 20 | 21 | 24 | 25 | 26 | 27 | 28 | Filters 29 | 30 | 31 | Minimum game count (7d) 32 | 33 | 37 | setConfig({ 38 | minGames: value, 39 | }) 40 | } 41 | /> 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Chart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChartConfiguration, 3 | Chart as ChartJs, 4 | ChartOptions, 5 | ChartTypeRegistry, 6 | } from "chart.js/auto"; 7 | import { createEffect, createSignal, onCleanup } from "solid-js"; 8 | 9 | type Props = { 10 | chart: ChartConfiguration; 11 | }; 12 | 13 | ChartJs.defaults.backgroundColor = "#00ff00"; 14 | ChartJs.defaults.borderColor = "#404040"; 15 | ChartJs.defaults.color = "#fff"; 16 | ChartJs.defaults.font.family = "Oswald, sans-serif"; 17 | ChartJs.defaults.font.size = 16; 18 | 19 | export function Chart(props: Props) { 20 | const [canvas, setCanvas] = createSignal(); 21 | const [chart, setChart] = createSignal>(); 22 | 23 | createEffect(() => { 24 | if (!canvas()) return; 25 | 26 | const config = { 27 | ...props.chart, 28 | options: { 29 | ...props.chart.options, 30 | maintainAspectRatio: false, 31 | plugins: { 32 | // @ts-ignore 33 | ...props.chart.options?.plugins, 34 | 35 | tooltip: { 36 | backgroundColor: "#444444", 37 | displayColors: false, 38 | // @ts-ignore 39 | ...props.chart.options?.plugins?.tooltip, 40 | }, 41 | }, 42 | } as ChartOptions, 43 | }; 44 | 45 | setChart((current) => { 46 | current?.destroy(); 47 | return new ChartJs(canvas()!, config); 48 | }); 49 | }); 50 | 51 | onCleanup(() => { 52 | chart()?.destroy(); 53 | }); 54 | 55 | return ; 56 | } 57 | -------------------------------------------------------------------------------- /packages/core/src/models/build/BuildDataset.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "../Role"; 2 | 3 | export type PartialBuildDataset = { 4 | championKey: string; 5 | role: Role; 6 | wins: number; 7 | games: number; 8 | 9 | runes: RunesBuildData; 10 | items: ItemsBuildData; 11 | summonerSpells: SummonerSpellsBuildData; 12 | skills: SkillsBuildData; 13 | }; 14 | 15 | export type EntityStats = { 16 | wins: number; 17 | games: number; 18 | }; 19 | 20 | export type FullBuildDataset = PartialBuildDataset & { 21 | matchups: BuildMatchupData[]; 22 | }; 23 | 24 | export type BuildMatchupData = { 25 | championKey: string; 26 | role: Role; 27 | wins: number; 28 | games: number; 29 | 30 | runes: RunesBuildData; 31 | items: ItemsBuildData; 32 | summonerSpells: SummonerSpellsBuildData; 33 | skills: SkillsBuildData; 34 | }; 35 | 36 | export type RunesBuildData = { 37 | primary: Record; 38 | secondary: Record; 39 | shards: { 40 | offense: Record; 41 | defense: Record; 42 | flex: Record; 43 | }; 44 | }; 45 | 46 | 47 | export type ItemsBuildData = { 48 | boots: Record; 49 | statsByOrder: Record[]; 50 | startingSets: Record; 51 | sets: Record; 52 | }; 53 | 54 | export type SummonerSpellsBuildData = Record; 55 | 56 | export type SummonerSpellStats = { 57 | wins: number; 58 | games: number; 59 | }; 60 | 61 | export type Skill = "Q" | "W" | "E" | "R"; 62 | export type SkillOrder = "QWE" | "QEW" | "WQE" | "WEQ" | "EQW" | "EWQ"; 63 | 64 | export type SkillsBuildData = { 65 | order: Record; 66 | level: Record[]; 67 | } 68 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/DatasetContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | JSXElement, 3 | createContext, 4 | createEffect, 5 | createResource, 6 | useContext, 7 | } from "solid-js"; 8 | import { 9 | DATASET_VERSION, 10 | Dataset, 11 | } from "@draftgap/core/src/models/dataset/Dataset"; 12 | 13 | const fetchDataset = async (name: "30-days" | "current-patch") => { 14 | try { 15 | const response = await fetch( 16 | `https://bucket.draftgap.com/datasets/v${DATASET_VERSION}/${name}.json` 17 | ); 18 | const json = await response.json(); 19 | return json as Dataset; 20 | } catch (err) { 21 | return undefined; 22 | } 23 | }; 24 | 25 | function createDatasetContext() { 26 | const [dataset] = createResource("current-patch", fetchDataset); 27 | 28 | const [dataset30Days] = createResource("30-days", fetchDataset); 29 | 30 | const isLoaded = () => 31 | dataset() !== undefined && dataset30Days() !== undefined; 32 | 33 | createEffect(() => { 34 | (window as any).DRAFTGAP_DEBUG = (window as any).DRAFTGAP_DEBUG || {}; 35 | (window as any).DRAFTGAP_DEBUG.dataset = dataset; 36 | (window as any).DRAFTGAP_DEBUG.dataset30Days = dataset30Days; 37 | }); 38 | 39 | return { 40 | dataset, 41 | dataset30Days, 42 | isLoaded, 43 | }; 44 | } 45 | 46 | const DatasetContext = createContext>(); 47 | 48 | export function DatasetProvider(props: { children: JSXElement }) { 49 | return ( 50 | 51 | {props.children} 52 | 53 | ); 54 | } 55 | 56 | export function useDataset() { 57 | const useCtx = useContext(DatasetContext); 58 | if (!useCtx) throw new Error("No DatasetContext found"); 59 | 60 | return useCtx; 61 | } 62 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/DraftSuggestionsContext.tsx: -------------------------------------------------------------------------------- 1 | import { JSXElement, createContext, createMemo, useContext } from "solid-js"; 2 | import { getSuggestions } from "@draftgap/core/src/draft/suggestions"; 3 | import { useDraftAnalysis } from "./DraftAnalysisContext"; 4 | import { useDataset } from "./DatasetContext"; 5 | 6 | export function createDraftSuggestionsContext() { 7 | const { isLoaded, dataset, dataset30Days } = useDataset(); 8 | const { draftAnalysisConfig, allyTeamComp, opponentTeamComp } = 9 | useDraftAnalysis(); 10 | 11 | const allySuggestions = createMemo(() => { 12 | if (!isLoaded()) return []; 13 | 14 | return getSuggestions( 15 | dataset()!, 16 | dataset30Days()!, 17 | allyTeamComp(), 18 | opponentTeamComp(), 19 | draftAnalysisConfig() 20 | ); 21 | }); 22 | 23 | const opponentSuggestions = createMemo(() => { 24 | if (!isLoaded()) return []; 25 | 26 | return getSuggestions( 27 | dataset()!, 28 | dataset30Days()!, 29 | opponentTeamComp(), 30 | allyTeamComp(), 31 | draftAnalysisConfig() 32 | ); 33 | }); 34 | 35 | return { allySuggestions, opponentSuggestions }; 36 | } 37 | 38 | export const DraftSuggestionsContext = 39 | createContext>(); 40 | 41 | export function DraftSuggestionsProvider(props: { children: JSXElement }) { 42 | return ( 43 | 46 | {props.children} 47 | 48 | ); 49 | } 50 | 51 | export function useDraftSuggestions() { 52 | const useCtx = useContext(DraftSuggestionsContext); 53 | if (!useCtx) throw new Error("No DraftSuggestionsContext found"); 54 | 55 | return useCtx; 56 | } 57 | -------------------------------------------------------------------------------- /apps/frontend/src/components/dialogs/UpdateDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Component, createSignal, onMount } from "solid-js"; 2 | import { checkUpdate, installUpdate } from "@tauri-apps/api/updater"; 3 | import { relaunch } from "@tauri-apps/api/process"; 4 | import { Button } from "../common/Button"; 5 | import { useMedia } from "../../hooks/useMedia"; 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | } from "../common/Dialog"; 13 | 14 | export const UpdateDialog: Component = () => { 15 | const { isDesktop } = useMedia(); 16 | const [isOpen, setIsOpen] = createSignal(false); 17 | 18 | onMount(() => { 19 | if (isDesktop) { 20 | (async () => { 21 | const update = await checkUpdate(); 22 | if (update.shouldUpdate) { 23 | setIsOpen(true); 24 | console.log("Update available, manifest:", update.manifest); 25 | } 26 | })(); 27 | } 28 | }); 29 | 30 | const update = async () => { 31 | setIsOpen(false); 32 | // display dialog 33 | await installUpdate(); 34 | // install complete, restart the app 35 | await relaunch(); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | 42 | Update available! 43 | 44 |

45 | A new version of DraftGap is available. 46 |

47 | 48 | 51 | 52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/TeamSelector.tsx: -------------------------------------------------------------------------------- 1 | import { For } from "solid-js"; 2 | import { useDraft } from "../../contexts/DraftContext"; 3 | import { Team } from "@draftgap/core/src/models/Team"; 4 | 5 | const TEAMS = ["ally", "opponent"] as const; 6 | 7 | export function TeamSelector() { 8 | const { selection, select, allyTeam, opponentTeam } = useDraft(); 9 | 10 | function selectTeam(team: Team) { 11 | const picks = team === "ally" ? allyTeam : opponentTeam; 12 | const index = picks.findIndex((pick) => !pick.championKey); 13 | 14 | select(team, index); 15 | } 16 | 17 | return ( 18 | 19 | 20 | {(team, i) => ( 21 | 40 | )} 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/core/src/draft/suggestions.ts: -------------------------------------------------------------------------------- 1 | import { Role, ROLES } from "../models/Role"; 2 | import { Dataset } from "../models/dataset/Dataset"; 3 | import { DraftResult, AnalyzeDraftConfig, analyzeDraft } from "./analysis"; 4 | import { getStats } from "./utils"; 5 | 6 | export interface Suggestion { 7 | championKey: string; 8 | role: Role; 9 | draftResult: DraftResult; 10 | } 11 | 12 | export function getSuggestions( 13 | dataset: Dataset, 14 | synergyMatchupDataset: Dataset, 15 | team: Map, 16 | enemy: Map, 17 | config: AnalyzeDraftConfig 18 | ) { 19 | const remainingRoles = ROLES.filter((role) => !team.has(role)); 20 | const enemyChampions = new Set(enemy.values()); 21 | const allyChampions = new Set(team.values()); 22 | 23 | const suggestions: Suggestion[] = []; 24 | 25 | for (const championKey of Object.keys(dataset.championData)) { 26 | if (enemyChampions.has(championKey) || allyChampions.has(championKey)) 27 | continue; 28 | 29 | for (const role of remainingRoles) { 30 | if (team.has(role)) continue; 31 | if ( 32 | (getStats(synergyMatchupDataset, championKey, role).games / 33 | 30) * 34 | 7 < 35 | config.minGames 36 | ) 37 | continue; 38 | 39 | team.set(role, championKey); 40 | const draftResult = analyzeDraft( 41 | dataset, 42 | synergyMatchupDataset, 43 | team, 44 | enemy, 45 | config 46 | ); 47 | team.delete(role); 48 | 49 | suggestions.push({ 50 | championKey, 51 | role, 52 | draftResult, 53 | }); 54 | } 55 | } 56 | 57 | return suggestions.sort( 58 | (a, b) => b.draftResult.winrate - a.draftResult.winrate 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /apps/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply bg-neutral-900 text-text font-header; 8 | } 9 | 10 | html, 11 | body { 12 | scroll-behavior: smooth; 13 | } 14 | 15 | button, 16 | [role="button"] { 17 | cursor: default; 18 | } 19 | } 20 | 21 | :root { 22 | color-scheme: dark; 23 | } 24 | 25 | @font-face { 26 | font-family: "Oswald"; 27 | src: url("/fonts/Oswald-VariableFont_wght.ttf") format("truetype"); 28 | font-display: swap; 29 | } 30 | 31 | @font-face { 32 | font-family: "OpenSans"; 33 | src: url("/fonts/OpenSans-VariableFont_wdth,wght.ttf") format("truetype"); 34 | font-display: swap; 35 | } 36 | 37 | /* Popper */ 38 | #arrow, 39 | #arrow::before { 40 | position: absolute; 41 | width: 8px; 42 | height: 8px; 43 | background: inherit; 44 | } 45 | 46 | #arrow { 47 | visibility: hidden; 48 | } 49 | 50 | #arrow::before { 51 | visibility: visible; 52 | content: ""; 53 | background-color: #262626; 54 | border-bottom: rgba(255, 255, 255, 0.2) solid 1px; 55 | border-right: rgba(255, 255, 255, 0.2) solid 1px; 56 | } 57 | 58 | #tooltip[data-popper-placement^="top"] #arrow { 59 | bottom: -4px; 60 | } 61 | #tooltip[data-popper-placement^="top"] #arrow::before { 62 | transform: rotate(45deg); 63 | } 64 | 65 | #tooltip[data-popper-placement^="bottom"] #arrow { 66 | top: -4px; 67 | } 68 | #tooltip[data-popper-placement^="bottom"] #arrow::before { 69 | transform: rotate(225deg); 70 | } 71 | 72 | #tooltip[data-popper-placement^="left"] #arrow { 73 | right: -4px; 74 | } 75 | #tooltip[data-popper-placement^="left"] #arrow::before { 76 | transform: rotate(-45deg); 77 | } 78 | 79 | #tooltip[data-popper-placement^="right"] #arrow { 80 | left: -4px; 81 | } 82 | #tooltip[data-popper-placement^="right"] #arrow::before { 83 | transform: rotate(135deg); 84 | } 85 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/toast.tsx: -------------------------------------------------------------------------------- 1 | import { check, exclamationCircle, star } from "solid-heroicons/outline"; 2 | import toast from "solid-toast"; 3 | import { Toast } from "../components/common/Toast"; 4 | 5 | export const createImportFavouritePicksToast = (onSubmit: () => void) => { 6 | return toast.custom((t) => ( 7 | 16 | )); 17 | }; 18 | 19 | export const createImportFavouritePicksSuccessToast = (amount: number) => { 20 | return toast.custom( 21 | (t) => ( 22 | 28 | ), 29 | { 30 | duration: 3000, 31 | } 32 | ); 33 | }; 34 | 35 | export const createErrorToast = (message: string) => { 36 | return toast.custom( 37 | (t) => ( 38 | 44 | ), 45 | { 46 | duration: 3000, 47 | } 48 | ); 49 | }; 50 | 51 | export const createMustSelectToast = () => { 52 | return toast.custom( 53 | (t) => ( 54 | 60 | ), 61 | { 62 | duration: 3000, 63 | } 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /packages/core/src/draft/utils.ts: -------------------------------------------------------------------------------- 1 | import { Dataset } from "../models/dataset/Dataset"; 2 | import { Role } from "../models/Role"; 3 | 4 | export function getStats( 5 | dataset: Dataset, 6 | championKey: string, 7 | role: Role 8 | ): { wins: number; games: number }; 9 | export function getStats( 10 | dataset: Dataset, 11 | championKey: string, 12 | role: Role, 13 | type: "duo", 14 | role2: Role, 15 | championKey2: string 16 | ): { wins: number; games: number }; 17 | export function getStats( 18 | dataset: Dataset, 19 | championKey: string, 20 | role: Role, 21 | type: "matchup", 22 | role2: Role, 23 | championKey2: string 24 | ): { wins: number; games: number }; 25 | export function getStats( 26 | dataset: Dataset, 27 | championKey: string, 28 | role: Role, 29 | type?: "matchup" | "duo", 30 | matchupDuoRole?: Role, 31 | matchupDuoChampionKey?: string 32 | ) { 33 | if (!type) { 34 | return ( 35 | dataset.championData[championKey]?.statsByRole[role] ?? { 36 | wins: 0, 37 | games: 0, 38 | } 39 | ); 40 | } 41 | 42 | if (type === "matchup") { 43 | matchupDuoRole = matchupDuoRole!; 44 | matchupDuoChampionKey = matchupDuoChampionKey!; 45 | return ( 46 | dataset.championData[championKey]?.statsByRole[role]?.matchup[ 47 | matchupDuoRole 48 | ][matchupDuoChampionKey] ?? { 49 | wins: 0, 50 | games: 0, 51 | } 52 | ); 53 | } else { 54 | matchupDuoRole = matchupDuoRole!; 55 | matchupDuoChampionKey = matchupDuoChampionKey!; 56 | 57 | return ( 58 | dataset.championData[championKey]?.statsByRole[role].synergy[ 59 | matchupDuoRole 60 | ][matchupDuoChampionKey] ?? { 61 | wins: 0, 62 | games: 0, 63 | } 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /apps/dataset/src/lolalytics/qwik-champion2.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "../utils"; 2 | import { LolalyticsRole } from "./roles"; 3 | 4 | export type LolalyticsChampion2Response = { 5 | team_h: string[]; 6 | team: Team; 7 | response: Response; 8 | }; 9 | 10 | export type Response = { 11 | valid: boolean; 12 | duration: string; 13 | }; 14 | 15 | export type Team = { 16 | top: Array; 17 | middle: Array; 18 | bottom: Array; 19 | support: Array; 20 | jungle: Array; 21 | }; 22 | 23 | export async function getLolalyticsQwikChampion2( 24 | patch: string, 25 | championId: string, 26 | role?: LolalyticsRole 27 | // matchupId?: string, 28 | // matchupRole?: LolalyticsRole 29 | ) { 30 | championId = championId.toLowerCase(); 31 | if (championId === "monkeyking") { 32 | championId = "wukong"; 33 | } 34 | // convert patch from 12.21.1 to 12.21 35 | patch = patch.split(".").slice(0, 2).join("."); 36 | 37 | // https://a1.lolalytics.com/mega/?ep=build-team&v=1&patch=14.19&c=wukong&lane=bottom&tier=emerald_plus&queue=ranked®ion=all 38 | const queryParams = new URLSearchParams(); 39 | queryParams.append("ep", "build-team"); 40 | queryParams.append("v", "1"); 41 | queryParams.append("tier", "emerald_plus"); 42 | queryParams.append("queue", "ranked"); 43 | queryParams.append("region", "all"); 44 | queryParams.append("patch", patch); 45 | queryParams.append("c", championId); 46 | queryParams.append("lane", role ?? "all"); // all is default? 47 | // if (matchupId && matchupRole) { 48 | // queryParams.append("matchup", matchupId); 49 | // queryParams.append("vslane", matchupRole); 50 | // } 51 | 52 | const res = await retry(() => 53 | fetch(`https://a1.lolalytics.com/mega/?${queryParams.toString()}`) 54 | ); 55 | 56 | const json = (await res.json()) as LolalyticsChampion2Response; 57 | 58 | return json; 59 | } 60 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/DamageDistributionBar.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from "solid-js"; 2 | import { Team } from "@draftgap/core/src/models/Team"; 3 | import { useDraftAnalysis } from "../../contexts/DraftAnalysisContext"; 4 | 5 | export function DamageDistributionBar(props: { team: Team }) { 6 | const { allyDamageDistribution, opponentDamageDistribution } = 7 | useDraftAnalysis(); 8 | 9 | const damageDistribution = () => 10 | props.team === "ally" 11 | ? allyDamageDistribution() 12 | : opponentDamageDistribution(); 13 | 14 | const totalDamage = () => 15 | damageDistribution()!.magic + 16 | damageDistribution()!.physical + 17 | damageDistribution()!.true; 18 | 19 | const magicPercentage = () => damageDistribution()!.magic / totalDamage(); 20 | const physicalPercentage = () => 21 | damageDistribution()!.physical / totalDamage(); 22 | const truePercentage = () => damageDistribution()!.true / totalDamage(); 23 | 24 | return ( 25 | 31 | 0 32 | } 33 | > 34 |
35 |
39 |
43 |
47 |
48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/ExtraDraftAnalysisContext.tsx: -------------------------------------------------------------------------------- 1 | import { JSXElement, createContext, createMemo, useContext } from "solid-js"; 2 | import { analyzeDraftExtra } from "@draftgap/core/src/draft/extra-analysis"; 3 | import { useDataset } from "./DatasetContext"; 4 | import { useUser } from "./UserContext"; 5 | import { useDraftAnalysis } from "./DraftAnalysisContext"; 6 | 7 | export function createExtraDraftAnalysisContext() { 8 | const { config } = useUser(); 9 | const { allyTeamComp, opponentTeamComp } = useDraftAnalysis(); 10 | const { dataset, dataset30Days } = useDataset(); 11 | 12 | const allyDraftExtraAnalysis = createMemo(() => { 13 | if (!dataset() || !dataset30Days()) return undefined; 14 | return analyzeDraftExtra( 15 | dataset()!, 16 | dataset30Days()!, 17 | allyTeamComp(), 18 | opponentTeamComp(), 19 | config 20 | ); 21 | }); 22 | 23 | const opponentDraftExtraAnalysis = createMemo(() => { 24 | if (!dataset() || !dataset30Days()) return undefined; 25 | return analyzeDraftExtra( 26 | dataset()!, 27 | dataset30Days()!, 28 | opponentTeamComp(), 29 | allyTeamComp(), 30 | config 31 | ); 32 | }); 33 | 34 | return { 35 | allyDraftExtraAnalysis, 36 | opponentDraftExtraAnalysis, 37 | }; 38 | } 39 | 40 | export const ExtraDraftAnalysisContext = 41 | createContext>(); 42 | 43 | export function ExtraDraftAnalysisProvider(props: { children: JSXElement }) { 44 | return ( 45 | 48 | {props.children} 49 | 50 | ); 51 | } 52 | 53 | export function useExtraDraftAnalysis() { 54 | const useCtx = useContext(ExtraDraftAnalysisContext); 55 | if (!useCtx) throw new Error("No DraftAnalysisContext found"); 56 | 57 | return useCtx; 58 | } 59 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/TeamOptions.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { ellipsisVertical } from "solid-heroicons/outline"; 3 | import { trash } from "solid-heroicons/solid-mini"; 4 | import { useDraft } from "../../contexts/DraftContext"; 5 | import { Team } from "@draftgap/core/src/models/Team"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuGroup, 10 | DropdownMenuIcon, 11 | DropdownMenuItem, 12 | DropdownMenuLabel, 13 | DropdownMenuSeparator, 14 | DropdownMenuTrigger, 15 | } from "../common/DropdownMenu"; 16 | import { As } from "@kobalte/core"; 17 | import { cn } from "../../utils/style"; 18 | import { buttonVariants } from "../common/Button"; 19 | 20 | type Props = { 21 | team: Team; 22 | }; 23 | export function TeamOptions(props: Props) { 24 | const { resetTeam } = useDraft(); 25 | 26 | return ( 27 |
28 | 29 | 30 | 37 | 38 | 39 | 40 | {props.team} team 41 | 42 | 43 | resetTeam(props.team)} 45 | > 46 | 47 | Reset team 48 | 49 | 50 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/dataset/src/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetObjectCommand, 3 | GetObjectCommandInput, 4 | PutBucketCorsCommand, 5 | PutObjectCommand, 6 | PutObjectCommandInput, 7 | } from "@aws-sdk/client-s3"; 8 | import { client } from "./client"; 9 | import { 10 | DATASET_VERSION, 11 | Dataset, 12 | } from "@draftgap/core/src/models/dataset/Dataset"; 13 | import { bytesToHumanReadable } from "../utils"; 14 | 15 | export async function getDataset({ name }: { name: string }) { 16 | const params = { 17 | Bucket: process.env.S3_BUCKET || "draftgap", 18 | Key: `datasets/v${DATASET_VERSION}/${name}.json`, 19 | } satisfies GetObjectCommandInput; 20 | const command = new GetObjectCommand(params); 21 | const response = await client.send(command); 22 | const body = await response.Body?.transformToString()!; 23 | return JSON.parse(body) as Dataset; 24 | } 25 | 26 | export async function storeDataset( 27 | dataset: Dataset, 28 | { name }: { name: string } 29 | ) { 30 | const params = { 31 | Bucket: process.env.S3_BUCKET || "draftgap", 32 | Key: `datasets/v${DATASET_VERSION}/${name}.json`, 33 | Body: JSON.stringify(dataset), 34 | ContentType: "application/json", 35 | } satisfies PutObjectCommandInput; 36 | const command = new PutObjectCommand(params); 37 | await client.send(command); 38 | 39 | const serialized = { 40 | byteLength: params.Body.length, 41 | }; 42 | console.log( 43 | `Stored dataset ${params.Bucket}/${ 44 | params.Key 45 | } of size ${bytesToHumanReadable(serialized.byteLength)}` 46 | ); 47 | 48 | const corsCommand = new PutBucketCorsCommand({ 49 | Bucket: process.env.S3_BUCKET || "draftgap", 50 | CORSConfiguration: { 51 | CORSRules: [ 52 | { 53 | AllowedHeaders: ["*"], 54 | AllowedMethods: ["GET"], 55 | AllowedOrigins: ["*"], 56 | MaxAgeSeconds: 3000, 57 | }, 58 | ], 59 | }, 60 | }); 61 | 62 | await client.send(corsCommand); 63 | } 64 | -------------------------------------------------------------------------------- /packages/core/src/builds/skill-analysis.ts: -------------------------------------------------------------------------------- 1 | import { AnalyzeDraftConfig } from "../draft/analysis"; 2 | import { 3 | PartialBuildDataset, 4 | FullBuildDataset, 5 | Skill, 6 | SkillOrder, 7 | } from "../models/build/BuildDataset"; 8 | import { EntityAnalysisResult, analyzeEntity } from "./entity-analysis"; 9 | 10 | export type SkillsAnalysisResult = { 11 | order: Record; 12 | levels: Record[]; 13 | } 14 | 15 | export function analyzeSkills( 16 | partialBuildDataset: PartialBuildDataset, 17 | fullBuildDataset: FullBuildDataset, 18 | config: AnalyzeDraftConfig 19 | ): SkillsAnalysisResult { 20 | const skills = { 21 | levels: [] as Record[], 22 | order: {}, 23 | } as SkillsAnalysisResult 24 | 25 | for (const skill of Object.keys(partialBuildDataset.skills.order) as SkillOrder[]) { 26 | skills.order[skill] = analyzeEntity( 27 | partialBuildDataset, 28 | fullBuildDataset, 29 | config, 30 | getSkillOrderStats, 31 | skill 32 | ); 33 | } 34 | 35 | for (let i = 0; i < partialBuildDataset.skills.level.length; i++) { 36 | skills.levels.push({} as Record); 37 | for (const skill of Object.keys(partialBuildDataset.skills.level[i]) as Skill[]) { 38 | skills.levels[i][skill] = analyzeEntity( 39 | partialBuildDataset, 40 | fullBuildDataset, 41 | config, 42 | getSkillLevelStats, 43 | { 44 | level: i, 45 | skill, 46 | } 47 | ); 48 | } 49 | } 50 | 51 | return skills; 52 | } 53 | 54 | function getSkillLevelStats(data: PartialBuildDataset, skill: { 55 | level: number; 56 | skill: Skill; 57 | }) { 58 | return data.skills.level[skill.level][skill.skill] ?? { wins: 0, games: 0 }; 59 | } 60 | 61 | function getSkillOrderStats(data: PartialBuildDataset, skillOrder: SkillOrder) { 62 | return data.skills.order[skillOrder] ?? { wins: 0, games: 0 }; 63 | } 64 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { For, JSX, mergeProps, splitProps } from "solid-js"; 2 | import { cn } from "../../utils/style"; 3 | 4 | export type ButtonGroupOption = { 5 | label: JSX.Element; 6 | value: T; 7 | }; 8 | 9 | interface Props { 10 | options: readonly ButtonGroupOption[]; 11 | selected: T; 12 | onChange: (value: T) => void; 13 | size?: "sm" | "md"; 14 | } 15 | 16 | export function ButtonGroup( 17 | _props: Props & Omit, "onChange"> 18 | ) { 19 | const mergedProps = mergeProps({ size: "md" }, _props); 20 | const [props, externalProps] = splitProps(mergedProps, [ 21 | "options", 22 | "selected", 23 | "onChange", 24 | "size", 25 | ]); 26 | return ( 27 |
34 | 35 | {(option, i) => ( 36 | 54 | )} 55 | 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/RoleFilter.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, For } from "solid-js"; 2 | import { useDraft } from "../../contexts/DraftContext"; 3 | import { ROLES } from "@draftgap/core/src/models/Role"; 4 | import { RoleIcon } from "../icons/roles/RoleIcon"; 5 | import { useDraftAnalysis } from "../../contexts/DraftAnalysisContext"; 6 | import { useDraftFilters } from "../../contexts/DraftFiltersContext"; 7 | import { cn } from "../../utils/style"; 8 | 9 | export function RoleFilter(props: ComponentProps<"span">) { 10 | const { selection, draftFinished } = useDraft(); 11 | const { roleFilter, setRoleFilter } = useDraftFilters(); 12 | const { getFilledRoles } = useDraftAnalysis(); 13 | 14 | const filledRoles = () => 15 | (selection.team && getFilledRoles(selection.team)) ?? new Set(); 16 | 17 | return ( 18 | 22 | 23 | {(role, i) => ( 24 | 42 | )} 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import colors from "tailwindcss/colors"; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 6 | theme: { 7 | extend: { 8 | colors: { 9 | primary: "#191919", 10 | secondary: "#d6b598", 11 | tertiary: "#FFD700", 12 | background: "#eeeeee", 13 | text: "#f4f4f4", 14 | ally: "#3b82f6", 15 | opponent: "#ef4444", 16 | winrate: { 17 | shiggo: "#ff4e50", 18 | meh: "#fcb1b2", 19 | okay: colors.neutral[50], 20 | good: "#7ea4f4", 21 | great: "#3273fa", 22 | volxd: "#ff9b00", 23 | }, 24 | }, 25 | fontFamily: { 26 | header: ["'Oswald'", "sans-serif"], 27 | body: [ 28 | "'OpenSans'", 29 | "-apple-system", 30 | "BlinkMacSystemFont", 31 | "Segoe UI", 32 | "Roboto", 33 | "Oxygen", 34 | "Ubuntu", 35 | "Cantarell", 36 | "Fira Sans", 37 | "Droid Sans", 38 | "Helvetica Neue", 39 | "sans-serif", 40 | ], 41 | }, 42 | animation: { 43 | enter: "enter 0.15s ease-out", 44 | leave: "leave 0.1s ease-in forwards", 45 | "dialog-enter": "enter 0.3s ease-out", 46 | "dialog-leave": "leave 0.2s ease-in forwards", 47 | }, 48 | keyframes: { 49 | enter: { 50 | "0%": { transform: "scale(0.95)", opacity: "0" }, 51 | "100%": { transform: "scale(1)", opacity: "1" }, 52 | }, 53 | leave: { 54 | "100%": { transform: "scale(0.95)", opacity: "0" }, 55 | "0%": { transform: "scale(1)", opacity: "1" }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | plugins: [require("@kobalte/tailwindcss")], 61 | }; 62 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@draftgap/frontend", 3 | "version": "2.13.0", 4 | "type": "module", 5 | "scripts": { 6 | "start": "vite", 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "serve": "vite preview", 10 | "test": "vitest", 11 | "lint": "eslint --ext .ts,.tsx src", 12 | "typecheck": "tsc --noEmit", 13 | "update-data": "node -r esbuild-register scripts/update-data.ts", 14 | "publish-tauri-update": "tsx scripts/publish-tauri-update.ts", 15 | "tauri-dev": "tauri dev" 16 | }, 17 | "dependencies": { 18 | "@aws-sdk/client-s3": "^3.462.0", 19 | "@draftgap/core": "workspace:*", 20 | "@formkit/auto-animate": "0.8.1", 21 | "@kobalte/core": "^0.11.2", 22 | "@kobalte/tailwindcss": "^0.9.0", 23 | "@octokit/rest": "^20.0.2", 24 | "@popperjs/core": "^2.11.8", 25 | "@tanstack/solid-query": "^5.12.0", 26 | "@tanstack/solid-table": "^8.10.7", 27 | "@tanstack/solid-virtual": "3.0.0-beta.6", 28 | "@tauri-apps/api": "^1.5.1", 29 | "@thisbeyond/solid-dnd": "^0.7.5", 30 | "chart.js": "^4.4.0", 31 | "class-variance-authority": "^0.7.0", 32 | "clsx": "^2.0.0", 33 | "date-fns": "^2.30.0", 34 | "dotenv": "^16.3.1", 35 | "solid-heroicons": "^3.2.4", 36 | "solid-js": "^1.8.6", 37 | "solid-popper": "^0.3.0", 38 | "solid-toast": "^0.5.0", 39 | "solid-transition-group": "^0.2.3", 40 | "tailwind-merge": "^2.0.0" 41 | }, 42 | "devDependencies": { 43 | "@tauri-apps/cli": "^1.5.6", 44 | "@types/gtag.js": "^0.0.18", 45 | "@types/node": "^20.10.1", 46 | "@typescript-eslint/eslint-plugin": "^6.13.1", 47 | "@typescript-eslint/parser": "^6.13.1", 48 | "autoprefixer": "^10.4.16", 49 | "esbuild": "^0.19.8", 50 | "esbuild-register": "^3.5.0", 51 | "eslint": "^8.54.0", 52 | "eslint-plugin-solid": "^0.13.0", 53 | "postcss": "^8.4.31", 54 | "tailwindcss": "^3.3.5", 55 | "tsx": "^4.6.1", 56 | "typescript": "^5.3.2", 57 | "vite": "^5.0.4", 58 | "vite-plugin-solid": "^2.7.2", 59 | "vitest": "^0.34.6" 60 | }, 61 | "packageManager": "pnpm@9.2.0" 62 | } -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/BuildView.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Match, Switch } from "solid-js"; 2 | import { RuneTable } from "./RuneTable"; 3 | import { useBuild } from "../../../contexts/BuildContext"; 4 | import { BootsStats } from "./BootsStats"; 5 | import { ItemStats } from "./ItemStats"; 6 | import { StarterItemStats } from "./StarterItemStats"; 7 | import { SummonerSpellsStats } from "./SummonerSpellsStats"; 8 | import { SkillStats } from "./SkillStats"; 9 | 10 | export const BuildView: Component = () => { 11 | const { query, buildAnalysisResult } = useBuild(); 12 | 13 | return ( 14 | <> 15 | 16 | 17 |
18 | Loading... 19 |
20 |
21 | 22 |
23 | Error while fetching build data 24 |
25 |
26 | 27 |
28 |
29 |

30 | Pre-game 31 |

32 | {/* */} 33 | 34 | 35 |
36 |
37 |

38 | In-game 39 |

40 | 41 | 42 | {/* */} 43 | 44 | 45 |
46 |
47 |
48 |
49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /apps/dataset/src/riot.ts: -------------------------------------------------------------------------------- 1 | export async function getVersions() { 2 | const res = await fetch( 3 | "https://ddragon.leagueoflegends.com/api/versions.json" 4 | ); 5 | const json = (await res.json()) as string[]; 6 | 7 | return json; 8 | } 9 | 10 | export type RiotChampion = { 11 | id: string; 12 | key: string; 13 | name: string; 14 | i18n: Record; 15 | }; 16 | 17 | export async function getChampions(version: string, locale = "en_US") { 18 | const res = await fetch( 19 | `https://ddragon.leagueoflegends.com/cdn/${version}/data/${locale}/champion.json` 20 | ); 21 | const json = (await res.json()) as { data: Record }; 22 | 23 | return Object.values(json.data).map((v: any) => ({ 24 | id: v.id, 25 | key: v.key, 26 | name: v.name, 27 | })); 28 | } 29 | 30 | export type RiotRunePath = { 31 | id: number; 32 | key: string; 33 | icon: string; 34 | name: string; 35 | slots: [RiotRuneSlot, RiotRuneSlot, RiotRuneSlot, RiotRuneSlot]; 36 | }; 37 | 38 | export type RiotRuneSlot = { 39 | runes: RiotRune[]; 40 | }; 41 | 42 | export type RiotRune = { 43 | id: number; 44 | key: string; 45 | icon: string; 46 | name: string; 47 | shortDesc: string; 48 | longDesc: string; 49 | }; 50 | 51 | export async function getRunes(version: string) { 52 | const res = await fetch( 53 | `https://ddragon.leagueoflegends.com/cdn/${version}/data/en_US/runesReforged.json` 54 | ); 55 | const json = (await res.json()) as RiotRunePath[]; 56 | 57 | return json; 58 | } 59 | 60 | export type RiotItem = { 61 | name: string; 62 | gold: { 63 | total: number; 64 | }; 65 | }; 66 | 67 | export async function getItems(version: string) { 68 | const res = await fetch( 69 | `https://ddragon.leagueoflegends.com/cdn/${version}/data/en_US/item.json` 70 | ); 71 | const json = (await res.json()) as { data: Record }; 72 | 73 | return json.data; 74 | } 75 | 76 | export type RiotSummonerSpell = { 77 | name: string; 78 | key: string; 79 | }; 80 | 81 | export async function getSummonerSpells(version: string) { 82 | const res = await fetch( 83 | `https://ddragon.leagueoflegends.com/cdn/${version}/data/en_US/summoner.json` 84 | ); 85 | const json = (await res.json()) as { 86 | data: Record; 87 | }; 88 | 89 | return json.data; 90 | } 91 | -------------------------------------------------------------------------------- /apps/frontend/src/components/LanguageMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { language } from "solid-heroicons/solid"; 3 | import { Component } from "solid-js"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuGroup, 8 | DropdownMenuItem, 9 | DropdownMenuLabel, 10 | DropdownMenuSeparator, 11 | DropdownMenuTrigger, 12 | } from "./common/DropdownMenu"; 13 | import { As } from "@kobalte/core"; 14 | import { cn } from "../utils/style"; 15 | import { buttonVariants } from "./common/Button"; 16 | import { useUser } from "../contexts/UserContext"; 17 | 18 | export const LanguageDropdownMenu: Component = () => { 19 | const { config, setConfig } = useUser(); 20 | 21 | return ( 22 | 23 | 24 | 31 | 32 | 33 | 34 | Language 35 | 36 | 37 | setConfig({ language: "en_US" })} 39 | class={cn( 40 | config.language === "en_US" && "bg-neutral-700" 41 | )} 42 | > 43 | English 44 | 45 | setConfig({ language: "zh_CN" })} 47 | class={cn( 48 | config.language === "zh_CN" && "bg-neutral-700" 49 | )} 50 | > 51 | Simplified Chinese 52 | 53 | 54 | 55 | 56 | Only affects champion names 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/TeamSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { For } from "solid-js"; 2 | import { ratingToWinrate } from "@draftgap/core/src/rating/ratings"; 3 | import { CountUp } from "../CountUp"; 4 | import { DamageDistributionBar } from "./DamageDistributionBar"; 5 | import { Pick } from "./Pick"; 6 | import { TeamOptions } from "./TeamOptions"; 7 | import { tooltip } from "../../directives/tooltip"; 8 | import { capitalize } from "../../utils/strings"; 9 | import { getRatingClass } from "../../utils/rating"; 10 | import { useDraftAnalysis } from "../../contexts/DraftAnalysisContext"; 11 | tooltip; 12 | 13 | interface IProps { 14 | team: "ally" | "opponent"; 15 | } 16 | 17 | export function TeamSidebar(props: IProps) { 18 | const { 19 | allyDraftAnalysis: allyDraftResult, 20 | opponentDraftAnalysis: opponentDraftResult, 21 | } = useDraftAnalysis(); 22 | 23 | const rating = () => 24 | props.team === "ally" 25 | ? allyDraftResult()?.totalRating 26 | : opponentDraftResult()?.totalRating; 27 | 28 | return ( 29 |
30 | 31 |
32 | {capitalize(props.team)} estimated winrate 38 | ), 39 | }} 40 | > 41 | {props.team.toUpperCase()} 42 |
43 | (value * 100).toFixed(2)} 46 | class={`${getRatingClass( 47 | rating() ?? 0 48 | )} transition-colors duration-500`} 49 | style={{ 50 | "font-variant-numeric": "tabular-nums", 51 | }} 52 | /> 53 |
54 |
55 | 56 | {(index) => } 57 | 58 | 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /apps/frontend/src/components/dialogs/WinrateDecompositionDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "solid-js"; 2 | import { SummaryCard } from "../views/analysis/SummaryCards"; 3 | import { hashtag, presentationChartLine } from "solid-heroicons/solid"; 4 | import { winrateToRating } from "@draftgap/core/src/rating/ratings"; 5 | import { DialogContent, DialogHeader, DialogTitle } from "../common/Dialog"; 6 | 7 | type Props = { 8 | data: { 9 | rating: number; 10 | games: number; 11 | wins: number; 12 | }; 13 | }; 14 | 15 | export const WinrateDecompositionDialog: Component = (props) => { 16 | return ( 17 | 18 | 19 | Winrate Decomposition 20 | 21 |
24 | 31 | Winrate draftgap uses in the model, more 32 | conservative than the real winrate. This winrate 33 | will heavily change depending on the risk level. 34 | 35 | } 36 | /> 37 | 44 | Raw normalized winrate from the sample of games from 45 | the dataset. 46 | 47 | } 48 | /> 49 | Number of games in the sample from the dataset. 56 | } 57 | /> 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /packages/core/src/draft/extra-analysis.ts: -------------------------------------------------------------------------------- 1 | import { defaultChampionRoleData } from "../models/dataset/ChampionRoleData"; 2 | import { Dataset } from "../models/dataset/Dataset"; 3 | import { Role } from "../models/Role"; 4 | import { winrateToRating } from "../rating/ratings"; 5 | import { priorGamesByRiskLevel } from "../risk/risk-level"; 6 | import { addStats } from "../stats"; 7 | import { AnalyzeDraftConfig } from "./analysis"; 8 | 9 | export function analyzeDraftExtra( 10 | dataset: Dataset, 11 | fullDataset: Dataset, 12 | team: Map, 13 | enemy: Map, 14 | config: AnalyzeDraftConfig 15 | ) { 16 | const priorGames = priorGamesByRiskLevel[config.riskLevel]; 17 | 18 | const ally = [...team.entries()]; 19 | const teamChampions = ally.map( 20 | ([role, champion]) => 21 | fullDataset.championData[champion]?.statsByRole[role] ?? 22 | defaultChampionRoleData() 23 | ); 24 | 25 | return { 26 | ratingByTime: Array.from({ length: 5 }).map((_, i) => { 27 | const championTimeRatings = teamChampions.map((champion) => { 28 | const championTime = champion.statsByTime[i]; 29 | 30 | const baseChampionStats = addStats(champion, { 31 | games: priorGames, 32 | wins: priorGames * 0.5, 33 | }); 34 | const baseChampionWinrate = 35 | baseChampionStats.wins / baseChampionStats.games; 36 | const baseChampionRating = winrateToRating(baseChampionWinrate); 37 | 38 | const championStats = addStats(championTime, { 39 | games: priorGames, 40 | wins: priorGames * baseChampionWinrate, 41 | }); 42 | const championTimeRating = winrateToRating( 43 | championStats.wins / championStats.games 44 | ); 45 | 46 | return championTimeRating - baseChampionRating; 47 | }); 48 | 49 | const totalRating = championTimeRatings.reduce( 50 | (acc, rating) => acc + rating, 51 | 0 52 | ); 53 | 54 | return { 55 | championResults: championTimeRatings.map((rating, i) => ({ 56 | championKey: ally[i][1], 57 | role: ally[i][0], 58 | rating, 59 | })), 60 | totalRating, 61 | }; 62 | }), 63 | }; 64 | } 65 | 66 | export type DraftExtraAnalysis = ReturnType; 67 | -------------------------------------------------------------------------------- /apps/frontend/src/components/CountUp.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | createEffect, 4 | createSignal, 5 | JSX, 6 | on, 7 | onMount, 8 | splitProps, 9 | } from "solid-js"; 10 | 11 | type Props = { 12 | value: number; 13 | formatFn?: (value: number) => string; 14 | } & JSX.HTMLAttributes; 15 | 16 | export const CountUp: Component = (props) => { 17 | const [, other] = splitProps(props, ["value", "formatFn"]); 18 | // eslint-disable-next-line solid/reactivity 19 | const [currentValue, setCurrentValue] = createSignal(props.value); 20 | const [currentReqAnimFrame, setCurrentReqAnimFrame] = createSignal< 21 | number | null 22 | >(null); 23 | 24 | onMount(() => { 25 | createEffect( 26 | on( 27 | () => props.value, 28 | () => { 29 | if (currentReqAnimFrame() !== null) { 30 | cancelAnimationFrame(currentReqAnimFrame()!); 31 | } 32 | 33 | const previousTime = performance.now(); 34 | 35 | const targetValue = props.value; 36 | 37 | const update = () => { 38 | setCurrentReqAnimFrame(null); 39 | 40 | const diff = targetValue - currentValue(); 41 | if (Math.abs(diff) < 0.0001) { 42 | setCurrentValue(targetValue); 43 | return; 44 | } 45 | 46 | const currentTime = performance.now(); 47 | const deltaTime = (currentTime - previousTime) / 1000; 48 | 49 | const changeSign = diff > 0 ? 1 : -1; 50 | const absoluteChange = 51 | Math.abs(diff * deltaTime) ** 1.6; 52 | let change = absoluteChange * changeSign; 53 | if (Math.abs(change) > Math.abs(diff)) { 54 | change = diff; 55 | } 56 | 57 | const newValue = currentValue() + change; 58 | setCurrentValue(newValue); 59 | 60 | const req = requestAnimationFrame(update); 61 | setCurrentReqAnimFrame(req); 62 | }; 63 | 64 | update(); 65 | } 66 | ) 67 | ); 68 | }); 69 | 70 | return ( 71 | 72 | {props.formatFn?.(currentValue()) ?? currentValue().toString()} 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /apps/frontend/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "pnpm run build", 5 | "beforeDevCommand": "pnpm run dev", 6 | "devPath": "http://localhost:3000", 7 | "distDir": "../dist" 8 | }, 9 | "package": { 10 | "productName": "DraftGap", 11 | "version": "2.13.0" 12 | }, 13 | "tauri": { 14 | "allowlist": { 15 | "all": false, 16 | "shell": { 17 | "all": false, 18 | "execute": false, 19 | "open": true, 20 | "scope": [], 21 | "sidecar": false 22 | } 23 | }, 24 | "bundle": { 25 | "active": true, 26 | "category": "DeveloperTool", 27 | "copyright": "", 28 | "deb": { 29 | "depends": [] 30 | }, 31 | "externalBin": [], 32 | "icon": [ 33 | "icons/32x32.png", 34 | "icons/128x128.png", 35 | "icons/128x128@2x.png", 36 | "icons/icon.icns", 37 | "icons/icon.ico" 38 | ], 39 | "identifier": "com.draftgap", 40 | "longDescription": "", 41 | "macOS": { 42 | "entitlements": null, 43 | "exceptionDomain": "", 44 | "frameworks": [], 45 | "providerShortName": null, 46 | "signingIdentity": null 47 | }, 48 | "resources": [], 49 | "shortDescription": "", 50 | "targets": "all", 51 | "windows": { 52 | "certificateThumbprint": null, 53 | "digestAlgorithm": "sha256", 54 | "timestampUrl": "" 55 | } 56 | }, 57 | "security": { 58 | "csp": null 59 | }, 60 | "updater": { 61 | "active": true, 62 | "endpoints": [ 63 | "https://gist.githubusercontent.com/vigovlugt/5d549f4fdd602eb22542ef55e7c881ec/raw" 64 | ], 65 | "dialog": false, 66 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI0M0FCNTUxM0RBNjNFNTgKUldSWVBxWTlVYlU2dEROZGVPUEZpVjNGbCsya1ozT0xjemEwaU8xcC9qd1AzbEs4SXAzNG9MZFgK" 67 | }, 68 | "windows": [ 69 | { 70 | "fullscreen": false, 71 | "height": 850, 72 | "resizable": true, 73 | "title": "DraftGap", 74 | "width": 1600 75 | } 76 | ] 77 | } 78 | } -------------------------------------------------------------------------------- /packages/core/src/role/role-predictor.ts: -------------------------------------------------------------------------------- 1 | import { Role, ROLES } from "../models/Role"; 2 | import { ChampionData } from "../models/dataset/ChampionData"; 3 | 4 | export function getTeamComps(champions: (ChampionData & { role?: Role })[]) { 5 | const existingTeam = new Map( 6 | champions 7 | .filter((c) => c.role !== undefined) 8 | .map((c) => [c.role!, c.key]) 9 | ); 10 | 11 | return getTeamCompsRecursive( 12 | [existingTeam, 1], 13 | champions.filter((c) => c.role === undefined) 14 | ).sort(([, a], [, b]) => b - a); 15 | } 16 | 17 | function getTeamCompsRecursive( 18 | [partialTeamComp, partialProbability]: [Map, number], 19 | champions: ChampionData[] 20 | ): [Map, number][] { 21 | if (champions.length === 0) { 22 | return [[partialTeamComp, partialProbability]]; 23 | } 24 | 25 | const champion = champions[0]; 26 | const remainingChampions = champions.slice(1); 27 | 28 | const totalGames = ROLES.reduce( 29 | (a, b) => a + champion.statsByRole[b].games, 30 | 0 31 | ); 32 | 33 | const combinations = []; 34 | for (const entry of Object.entries(champion.statsByRole)) { 35 | const role = Number(entry[0]) as Role; 36 | const roleData = entry[1]; 37 | if (partialTeamComp.has(role)) { 38 | continue; 39 | } 40 | 41 | const roleProbability = roleData.games / totalGames; 42 | 43 | const newPartialTeamComp = new Map(partialTeamComp); 44 | newPartialTeamComp.set(role, champion.key); 45 | combinations.push( 46 | ...getTeamCompsRecursive( 47 | [newPartialTeamComp, partialProbability * roleProbability], 48 | remainingChampions 49 | ) 50 | ); 51 | } 52 | 53 | return combinations; 54 | } 55 | 56 | export default function predictRoles( 57 | teamComps: [Map, number][] 58 | ): Map> { 59 | const totalProbability = teamComps.reduce( 60 | (sum, teamComp) => sum + teamComp[1], 61 | 0 62 | ); 63 | 64 | const probabilities = new Map>(); 65 | 66 | for (const [championRoles, probability] of teamComps) { 67 | for (const [role, championKey] of championRoles) { 68 | if (!probabilities.has(championKey)) { 69 | probabilities.set(championKey, new Map()); 70 | } 71 | 72 | const championProbabilities = probabilities.get(championKey)!; 73 | championProbabilities.set( 74 | role, 75 | (championProbabilities.get(role) ?? 0) + 76 | probability / totalProbability 77 | ); 78 | } 79 | } 80 | 81 | return probabilities; 82 | } 83 | -------------------------------------------------------------------------------- /apps/dataset/src/lolalytics/champion2.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "../utils"; 2 | import { LolalyticsRole } from "./roles"; 3 | 4 | export interface LolalyticsChampion2Response { 5 | skills: Skills; 6 | itemSets: ItemSets; 7 | startSet: Array>; 8 | earlySet: Array>; 9 | team_top: [number, number, number, number][] | undefined; 10 | team_jungle: [number, number, number, number][] | undefined; 11 | team_middle: [number, number, number, number][] | undefined; 12 | team_bottom: [number, number, number, number][] | undefined; 13 | team_support: [number, number, number, number][] | undefined; 14 | key: string; 15 | cache: string; 16 | response: Response; 17 | } 18 | 19 | interface ItemSets { 20 | itemSet1: { [key: string]: number[] }; 21 | itemSet2: { [key: string]: number[] }; 22 | itemSet3: { [key: string]: number[] }; 23 | itemSet4: { [key: string]: number[] }; 24 | itemSet5: { [key: string]: number[] }; 25 | itemBootSet1: { [key: string]: number[] }; 26 | itemBootSet2: { [key: string]: number[] }; 27 | itemBootSet3: { [key: string]: number[] }; 28 | itemBootSet4: { [key: string]: number[] }; 29 | itemBootSet5: { [key: string]: number[] }; 30 | itemBootSet6: { [key: string]: number[] }; 31 | } 32 | 33 | interface Response { 34 | platform: string; 35 | version: number; 36 | endPoint: string; 37 | valid: boolean; 38 | duration: string; 39 | } 40 | 41 | interface Skills { 42 | skillEarly: Array>; 43 | skillOrder: Array>; 44 | skill6: Array; 45 | skill6Pick: number; 46 | skill10: Array; 47 | skill10Pick: number; 48 | skill15: Array; 49 | skill15Pick: number; 50 | } 51 | 52 | export async function getLolalyticsChampion2( 53 | patch: string, 54 | championKey: string, 55 | role: LolalyticsRole | "default" = "default", 56 | matchup?: string, 57 | matchupRole?: LolalyticsRole 58 | ) { 59 | // convert patch from 12.21.1 to 12.21 60 | patch = patch.split(".").slice(0, 2).join("."); 61 | 62 | const queryParams = new URLSearchParams(); 63 | queryParams.append("ep", "champion2"); 64 | queryParams.append("p", "d"); 65 | queryParams.append("v", "1"); 66 | queryParams.append("tier", "emerald_plus"); 67 | queryParams.append("queue", "420"); 68 | queryParams.append("region", "all"); 69 | queryParams.append("patch", patch); 70 | queryParams.append("cid", championKey); 71 | queryParams.append("lane", role); 72 | if (matchup && matchupRole) { 73 | queryParams.append("matchup", matchup); 74 | queryParams.append("vslane", matchupRole); 75 | } 76 | 77 | const res = await retry(() => 78 | fetch(`https://ax.lolalytics.com/mega/?${queryParams.toString()}`) 79 | ); 80 | 81 | const json = (await res.json()) as LolalyticsChampion2Response; 82 | 83 | return json; 84 | } 85 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/BootsStats.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "solid-js"; 2 | import { useBuild } from "../../../contexts/BuildContext"; 3 | import { Panel, PanelHeader } from "../../common/Panel"; 4 | import { formatPercentage, getRatingClass } from "../../../utils/rating"; 5 | import { ratingToWinrate } from "@draftgap/core/src/rating/ratings"; 6 | import { HorizontalEntityStats } from "./EntityStats"; 7 | import { useDataset } from "../../../contexts/DatasetContext"; 8 | 9 | export const BootsStats: Component = () => { 10 | const { buildAnalysisResult, partialBuildDataset } = useBuild(); 11 | 12 | return ( 13 | 14 | Boots 15 | 18 | partialBuildDataset()!.items.boots[parseInt(id)].games / 19 | partialBuildDataset()!.games > 20 | 0.01 21 | )} 22 | getGames={(id) => partialBuildDataset()!.items.boots[id].games} 23 | getRating={(id) => 24 | buildAnalysisResult()!.items.boots[id].totalRating 25 | } 26 | > 27 | {([id]) => } 28 | 29 | 30 | ); 31 | }; 32 | 33 | const Boot: Component<{ itemId: number }> = (props) => { 34 | const { dataset } = useDataset(); 35 | const { partialBuildDataset, buildAnalysisResult, setSelectedEntity } = 36 | useBuild(); 37 | 38 | const result = () => buildAnalysisResult()!.items.boots[props.itemId]; 39 | const data = () => partialBuildDataset()!.items.boots[props.itemId]; 40 | 41 | return ( 42 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/BuildsView.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from "solid-js"; 2 | import { Team } from "@draftgap/core/src/models/Team"; 3 | import { ViewTabs } from "../../common/ViewTabs"; 4 | import { useDraft } from "../../../contexts/DraftContext"; 5 | import { overflowEllipsis } from "../../../utils/strings"; 6 | import { BuildView } from "./BuildView"; 7 | import { useBuild } from "../../../contexts/BuildContext"; 8 | import { useDataset } from "../../../contexts/DatasetContext"; 9 | import { Dialog } from "../../common/Dialog"; 10 | import { BuildAnalysisDialog } from "../../dialogs/BuildAnalysisDialog"; 11 | 12 | export const BuildsViewTabs = (props: { team: Team }) => { 13 | const { allyTeam, opponentTeam } = useDraft(); 14 | const { dataset } = useDataset(); 15 | const { buildPick, setBuildPick } = useBuild(); 16 | 17 | const team = () => (props.team === "ally" ? allyTeam : opponentTeam); 18 | 19 | return ( 20 | i) 24 | .filter((i) => team()[i].championKey !== undefined) 25 | .map((i) => ({ 26 | value: { team: props.team, index: i }, 27 | label: overflowEllipsis( 28 | dataset()!.championData[team()[i].championKey!].name, 29 | 10 30 | ), 31 | }))} 32 | selected={buildPick()} 33 | onChange={setBuildPick} 34 | equals={(a, b) => a?.team === b?.team && a?.index === b?.index} 35 | class="!w-auto !border-b-0" 36 | /> 37 | ); 38 | }; 39 | 40 | export const BuildsView = () => { 41 | const { 42 | buildPick, 43 | selectedEntity, 44 | setSelectedEntity, 45 | showSelectedEntity, 46 | buildAnalysisResult, 47 | } = useBuild(); 48 | 49 | return ( 50 | <> 51 |
52 | 53 | 54 |
55 |
56 | 60 | Select a champion to view their build 61 |
62 | } 63 | > 64 | 65 | 66 |
67 | 70 | setSelectedEntity(undefined)} 73 | > 74 | 75 | 76 | 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /apps/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from "solid-js/web"; 3 | 4 | import "./index.css"; 5 | import App from "./App"; 6 | import { DraftProvider } from "./contexts/DraftContext"; 7 | import { LolClientProvider } from "./contexts/LolClientContext"; 8 | import { setupAnalytics } from "./utils/analytics"; 9 | import { TooltipProvider } from "./contexts/TooltipContext"; 10 | import { Toaster } from "solid-toast"; 11 | import { setupMobileVH } from "./utils/mobile"; 12 | import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; 13 | import { BuildProvider } from "./contexts/BuildContext"; 14 | import { DraftViewProvider } from "./contexts/DraftViewContext"; 15 | import { UserProvider } from "./contexts/UserContext"; 16 | import { DraftSuggestionsProvider } from "./contexts/DraftSuggestionsContext"; 17 | import { DraftAnalysisProvider } from "./contexts/DraftAnalysisContext"; 18 | import { DatasetProvider } from "./contexts/DatasetContext"; 19 | import { DraftFiltersProvider } from "./contexts/DraftFiltersContext"; 20 | import { ExtraDraftAnalysisProvider } from "./contexts/ExtraDraftAnalysisContext"; 21 | 22 | setupMobileVH(); 23 | setupAnalytics(); 24 | 25 | const queryClient = new QueryClient(); 26 | 27 | render( 28 | () => ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ), 62 | document.getElementById("root") as HTMLElement 63 | ); 64 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/TooltipContext.tsx: -------------------------------------------------------------------------------- 1 | import { Placement } from "@popperjs/core"; 2 | import { 3 | createContext, 4 | createEffect, 5 | createSignal, 6 | JSX, 7 | Show, 8 | useContext, 9 | } from "solid-js"; 10 | import usePopper from "solid-popper"; 11 | import { Transition } from "solid-transition-group"; 12 | 13 | export const createTooltipContext = () => { 14 | const [popoverContent, setPopoverContent] = 15 | createSignal("TEST!"); 16 | const [popoverPlacement, setPopoverPlacement] = 17 | createSignal("auto"); 18 | const [popoverTarget, setPopoverTarget] = createSignal( 19 | null 20 | ); 21 | const [popoverVisible, setPopoverVisible] = createSignal(false); 22 | 23 | return { 24 | popoverContent, 25 | setPopoverContent, 26 | popoverPlacement, 27 | setPopoverPlacement, 28 | popoverTarget, 29 | setPopoverTarget, 30 | popoverVisible, 31 | setPopoverVisible, 32 | }; 33 | }; 34 | 35 | export const TooltipContext = 36 | createContext>(); 37 | 38 | export function TooltipProvider(props: { children: JSX.Element }) { 39 | const ctx = createTooltipContext(); 40 | const [popper, setPopper] = createSignal(); 41 | 42 | createEffect(() => { 43 | usePopper(ctx.popoverTarget, popper, { 44 | placement: ctx.popoverPlacement(), 45 | modifiers: [ 46 | { 47 | name: "offset", 48 | options: { 49 | offset: [0, 8], 50 | }, 51 | }, 52 | ], 53 | }); 54 | }); 55 | 56 | return ( 57 | 58 | {props.children} 59 |
60 | 68 | 69 |
74 |
75 | {ctx.popoverContent()} 76 |
77 |
78 |
79 | 80 | 81 |
82 | 83 | ); 84 | } 85 | 86 | export const useTooltip = () => { 87 | const useCtx = useContext(TooltipContext); 88 | if (!useCtx) throw new Error("No TooltipContext found"); 89 | 90 | return useCtx; 91 | }; 92 | -------------------------------------------------------------------------------- /packages/core/src/builds/rune-analysis.ts: -------------------------------------------------------------------------------- 1 | import { AnalyzeDraftConfig } from "../draft/analysis"; 2 | import { 3 | PartialBuildDataset, 4 | FullBuildDataset, 5 | RunesBuildData, 6 | } from "../models/build/BuildDataset"; 7 | import { EntityAnalysisResult, analyzeEntity } from "./entity-analysis"; 8 | 9 | const RUNE_TYPES = [ 10 | "primary", 11 | "secondary", 12 | "shard-offense", 13 | "shard-defense", 14 | "shard-flex", 15 | ] as const; 16 | type RuneType = (typeof RUNE_TYPES)[number]; 17 | 18 | export type RunesAnalysisResult = { 19 | primary: Record; 20 | secondary: Record; 21 | shards: { 22 | offense: Record; 23 | defense: Record; 24 | flex: Record; 25 | }; 26 | }; 27 | 28 | export function analyzeRunes( 29 | partialBuildDataset: PartialBuildDataset, 30 | fullBuildDataset: FullBuildDataset, 31 | config: AnalyzeDraftConfig 32 | ): RunesAnalysisResult { 33 | const analyze = (runeType: RuneType) => { 34 | const runeIds = Object.keys( 35 | getRuneStatsMap(partialBuildDataset.runes, runeType) 36 | ); 37 | return runeIds.reduce((result, runeId) => { 38 | const runeIdNumber = parseInt(runeId); 39 | const runeResult = analyzeEntity( 40 | partialBuildDataset, 41 | fullBuildDataset, 42 | config, 43 | getRuneStats, 44 | { 45 | type: runeType, 46 | id: runeIdNumber, 47 | } 48 | ); 49 | result[runeId] = runeResult; 50 | return result; 51 | }, {} as Record); 52 | }; 53 | return { 54 | primary: analyze("primary"), 55 | secondary: analyze("secondary"), 56 | shards: { 57 | offense: analyze("shard-offense"), 58 | defense: analyze("shard-defense"), 59 | flex: analyze("shard-flex"), 60 | }, 61 | }; 62 | } 63 | 64 | function getRuneStatsMap(runeBuildData: RunesBuildData, runeType: RuneType) { 65 | switch (runeType) { 66 | case "primary": 67 | return runeBuildData.primary; 68 | case "secondary": 69 | return runeBuildData.secondary; 70 | case "shard-offense": 71 | return runeBuildData.shards.offense; 72 | case "shard-defense": 73 | return runeBuildData.shards.defense; 74 | case "shard-flex": 75 | return runeBuildData.shards.flex; 76 | } 77 | } 78 | 79 | function getRuneStats( 80 | data: PartialBuildDataset, 81 | rune: { 82 | type: RuneType; 83 | id: number; 84 | } 85 | ) { 86 | switch (rune.type) { 87 | case "primary": 88 | return data.runes.primary[rune.id]; 89 | case "secondary": 90 | return data.runes.secondary[rune.id]; 91 | case "shard-offense": 92 | return data.runes.shards.offense[rune.id]; 93 | case "shard-defense": 94 | return data.runes.shards.defense[rune.id]; 95 | case "shard-flex": 96 | return data.runes.shards.flex[rune.id]; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /scripts/bump-version.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, readdirSync, statSync } from "fs"; 2 | import { join, basename } from "path"; 3 | import { exec } from "child_process"; 4 | 5 | function getNextVersion(currentVersion: string, versionType: string): string { 6 | const versionParts = currentVersion.split("."); 7 | const major = parseInt(versionParts[0], 10); 8 | const minor = parseInt(versionParts[1], 10); 9 | const patch = parseInt(versionParts[2], 10); 10 | 11 | switch (versionType) { 12 | case "PATCH": 13 | return `${major}.${minor}.${patch + 1}`; 14 | case "MINOR": 15 | return `${major}.${minor + 1}.0`; 16 | case "MAJOR": 17 | return `${major + 1}.0.0`; 18 | default: 19 | throw new Error(`Invalid version type: ${versionType}`); 20 | } 21 | } 22 | 23 | function getPaths(dir: string, fileName: string): string[] { 24 | return readdirSync(dir) 25 | .map((f) => join(dir, f)) 26 | .reduce((paths, path) => { 27 | const file = basename(path); 28 | if (file === "node_modules" || file === "target") { 29 | return paths; 30 | } 31 | 32 | const stat = statSync(path); 33 | if (stat.isDirectory()) { 34 | return paths.concat(getPaths(path, fileName)); 35 | } else if (stat.isFile() && file === fileName) { 36 | return paths.concat(path); 37 | } 38 | 39 | return paths; 40 | }, [] as string[]); 41 | } 42 | 43 | export function main() { 44 | const args = process.argv.slice(2); 45 | if (args.length !== 1) { 46 | console.error("Usage: pnpm bump:version PATCH|MINOR|MAJOR"); 47 | return; 48 | } 49 | const versionType = args[0].toUpperCase(); 50 | 51 | const mainPackage = JSON.parse(readFileSync("package.json", "utf8")); 52 | const currentVersion = mainPackage.version; 53 | const nextVersion = getNextVersion(currentVersion, versionType); 54 | 55 | // Update package.json 56 | const files = [ 57 | "package.json", 58 | ...getPaths("./apps", "package.json"), 59 | ...getPaths("./packages", "package.json"), 60 | ]; 61 | for (const file of files) { 62 | const packageJson = JSON.parse(readFileSync(file, "utf8")); 63 | packageJson.version = nextVersion; 64 | writeFileSync(file, JSON.stringify(packageJson, null, 4)); 65 | } 66 | 67 | // Update tauri.conf.json 68 | const tauriConfJson = JSON.parse( 69 | readFileSync("apps/frontend/src-tauri/tauri.conf.json", "utf8") 70 | ); 71 | tauriConfJson.package.version = nextVersion; 72 | writeFileSync( 73 | "apps/frontend/src-tauri/tauri.conf.json", 74 | JSON.stringify(tauriConfJson, null, 4) 75 | ); 76 | 77 | console.log(`Bumped version from ${currentVersion} to ${nextVersion}`); 78 | 79 | console.log("Releasing new version..."); 80 | 81 | const cmd = `git commit -am "Release ${nextVersion}" && git tag v${nextVersion} && git push && git push --tags`; 82 | console.log(cmd); 83 | 84 | exec(cmd, (err, stdout, stderr) => { 85 | if (err) { 86 | console.error(err); 87 | return; 88 | } 89 | 90 | console.log(stdout); 91 | console.error(stderr); 92 | }); 93 | } 94 | 95 | main(); 96 | -------------------------------------------------------------------------------- /apps/frontend/src/components/OptionsMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { ellipsisVertical } from "solid-heroicons/solid"; 3 | import { 4 | cog_6Tooth, 5 | envelope, 6 | globeAlt, 7 | heart, 8 | questionMarkCircle, 9 | } from "solid-heroicons/solid-mini"; 10 | import { Component } from "solid-js"; 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuGroup, 15 | DropdownMenuIcon, 16 | DropdownMenuItem, 17 | DropdownMenuLabel, 18 | DropdownMenuSeparator, 19 | DropdownMenuTrigger, 20 | } from "./common/DropdownMenu"; 21 | import { As } from "@kobalte/core"; 22 | import { cn } from "../utils/style"; 23 | import { buttonVariants } from "./common/Button"; 24 | 25 | type Props = { 26 | setShowSettings: (show: boolean) => void; 27 | setShowFAQ: (show: boolean) => void; 28 | }; 29 | 30 | export const OptionsDropdownMenu: Component = (props) => { 31 | return ( 32 | 33 | 34 | 41 | 42 | 43 | 44 | Draftgap 45 | 46 | 47 | props.setShowSettings(true)} 49 | > 50 | 51 | Settings 52 | 53 | props.setShowFAQ(true)}> 54 | 55 | FAQ 56 | 57 | 59 | window.open("mailto:vigovlugt+draftgap@gmail.com") 60 | } 61 | > 62 | 63 | Contact 64 | 65 | 67 | window.open("https://leagueofitems.com") 68 | } 69 | > 70 | 71 | LeagueOfItems 72 | 73 | 75 | window.open( 76 | "https://www.buymeacoffee.com/vigovlugt" 77 | ) 78 | } 79 | > 80 | 81 | Donate 82 | 83 | 84 | 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/LolClientStatusBadge.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Match, Switch } from "solid-js"; 2 | import { ClientState, useLolClient } from "../../contexts/LolClientContext"; 3 | import { createErrorToast } from "../../utils/toast"; 4 | import { Badge } from "../common/Badge"; 5 | import { useMedia } from "../../hooks/useMedia"; 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "../common/Dialog"; 13 | import { Icon } from "solid-heroicons"; 14 | import { questionMarkCircle } from "solid-heroicons/solid-mini"; 15 | 16 | type Props = { 17 | setShowDownloadModal: (show: boolean) => void; 18 | }; 19 | 20 | export const LolClientStatusBadge: Component = (props) => { 21 | const { isDesktop } = useMedia(); 22 | const { clientState, clientError } = useLolClient(); 23 | 24 | return ( 25 | 26 | 27 | props.setShowDownloadModal(true)} 30 | theme="primary" 31 | class="hidden md:block" 32 | > 33 | Sync with league client 34 | 35 | 36 | 37 | Disabled 38 | 39 | 40 | Connected 41 | 42 | 43 | Champ Select 44 | 45 | 46 | 47 | 48 | 52 | Not Connected 53 | 57 | 58 | 59 | 60 | 61 | 62 | Can't find League Client 63 | 64 |

65 | Could not find the League of Legends client. Make 66 | sure it's running and you're logged in. If that 67 | doesn't work, try starting DraftGap as 68 | administrator. 69 |

70 |

71 | Admin error: 72 |
73 | 77 | {clientError() ?? "No error"} 78 | 79 |

80 |
81 |
82 |
83 |
84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /apps/frontend/src/components/draft/Search.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { magnifyingGlass, xMark } from "solid-heroicons/outline"; 3 | import { onCleanup, onMount, Show } from "solid-js"; 4 | import { useDraftFilters } from "../../contexts/DraftFiltersContext"; 5 | import { useUser } from "../../contexts/UserContext"; 6 | 7 | export function Search() { 8 | const { search, setSearch } = useDraftFilters(); 9 | const { setConfig } = useUser(); 10 | 11 | // eslint-disable-next-line prefer-const -- solid js ref 12 | let inputEl: HTMLInputElement | undefined = undefined; 13 | 14 | function onInput(e: Event) { 15 | const input = e.currentTarget as HTMLInputElement; 16 | setSearch(input.value); 17 | if (input.value === "DANGEROUSLY_ENABLE_BETA_FEATURES") { 18 | setConfig((config) => ({ ...config, enableBetaFeatures: true })); 19 | setSearch(""); 20 | } 21 | if (input.value === "DANGEROUSLY_DISABLE_BETA_FEATURES") { 22 | setConfig((config) => ({ ...config, enableBetaFeatures: false })); 23 | setSearch(""); 24 | } 25 | } 26 | 27 | onMount(() => { 28 | if (!inputEl) return; 29 | const el = inputEl as HTMLInputElement; 30 | 31 | const onControlF = (e: KeyboardEvent) => { 32 | if (e.ctrlKey && (e.key === "f" || e.key == "k")) { 33 | e.preventDefault(); 34 | el.focus(); 35 | } 36 | }; 37 | window.addEventListener("keydown", onControlF); 38 | 39 | const onTabOrEnter = (e: KeyboardEvent) => { 40 | if (e.key === "Tab" || e.key === "Enter") { 41 | e.preventDefault(); 42 | const firstTableRow = document.querySelector("table tbody tr"); 43 | if (firstTableRow) { 44 | (firstTableRow as HTMLElement).focus(); 45 | } 46 | } 47 | }; 48 | 49 | el.addEventListener("keydown", onTabOrEnter); 50 | onCleanup(() => { 51 | el.removeEventListener("keydown", onTabOrEnter); 52 | window.removeEventListener("keydown", onControlF); 53 | }); 54 | }); 55 | 56 | return ( 57 |
58 |
59 |
60 |
66 | 74 | 75 | 85 | 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | JSXElement, 3 | createContext, 4 | createEffect, 5 | createSignal, 6 | useContext, 7 | } from "solid-js"; 8 | import { createStore } from "solid-js/store"; 9 | import { Role } from "@draftgap/core/src/models/Role"; 10 | import { DraftGapConfig } from "@draftgap/core/src/models/user/Config"; 11 | 12 | type FavouritePick = `${string}:${Role}`; 13 | 14 | const DEFAULT_CONFIG: DraftGapConfig = { 15 | // DRAFT CONFIG 16 | ignoreChampionWinrates: false, 17 | riskLevel: "medium", 18 | minGames: 1000, 19 | 20 | // UI 21 | showFavouritesAtTop: false, 22 | banPlacement: "bottom", 23 | unownedPlacement: "bottom", 24 | showAdvancedWinrates: false, 25 | language: "en_US", 26 | 27 | // MISC 28 | defaultStatsSite: "lolalytics", 29 | enableBetaFeatures: false, 30 | 31 | // LOL CLIENT 32 | disableLeagueClientIntegration: false, 33 | }; 34 | 35 | const FAVOURITE_PICKS_KEY = "draftgap-favourite-picks"; 36 | const CONFIG_KEY = "draftgap-config"; 37 | 38 | function createConfig() { 39 | const partialInitialConfig = JSON.parse( 40 | localStorage.getItem(CONFIG_KEY) || "{}" 41 | ); 42 | 43 | const [config, setConfig] = createStore({ 44 | ...DEFAULT_CONFIG, 45 | ...partialInitialConfig, 46 | }); 47 | createEffect(() => { 48 | localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); 49 | }); 50 | 51 | return [config, setConfig] as const; 52 | } 53 | 54 | function createFavouritePicks() { 55 | const favouriteInitial = localStorage.getItem(FAVOURITE_PICKS_KEY); 56 | const favouriteInitialParsed = JSON.parse(favouriteInitial || "[]"); 57 | 58 | const [favouritePicks, setFavouritePicks] = createSignal< 59 | Set 60 | >(new Set(favouriteInitialParsed)); 61 | createEffect(() => { 62 | localStorage.setItem( 63 | "draftgap-favourite-picks", 64 | JSON.stringify([...favouritePicks()]) 65 | ); 66 | }); 67 | 68 | return [favouritePicks, setFavouritePicks] as const; 69 | } 70 | 71 | function createUserContext() { 72 | const [config, setConfig] = createConfig(); 73 | const [favouritePicks, setFavouritePicks] = createFavouritePicks(); 74 | 75 | function setFavourite(championKey: string, role: Role, value: boolean) { 76 | const favouritePick: FavouritePick = `${championKey}:${role}`; 77 | const newFavourites = new Set(favouritePicks()); 78 | 79 | if (value) { 80 | newFavourites.add(favouritePick); 81 | } else { 82 | newFavourites.delete(favouritePick); 83 | } 84 | 85 | setFavouritePicks(newFavourites); 86 | } 87 | 88 | const isFavourite = (championKey: string, role: Role) => { 89 | const favouritePick: FavouritePick = `${championKey}:${role}`; 90 | 91 | return favouritePicks().has(favouritePick); 92 | }; 93 | 94 | return { 95 | config, 96 | setConfig, 97 | favouritePicks, 98 | setFavourite, 99 | isFavourite, 100 | }; 101 | } 102 | 103 | const UserContext = 104 | createContext>(undefined); 105 | 106 | export function UserProvider(props: { children: JSXElement }) { 107 | const ctx = createUserContext(); 108 | 109 | return ( 110 | 111 | {props.children} 112 | 113 | ); 114 | } 115 | 116 | export function useUser() { 117 | const useCtx = useContext(UserContext); 118 | if (!useCtx) throw new Error("No UserContext found"); 119 | 120 | return useCtx; 121 | } 122 | -------------------------------------------------------------------------------- /packages/core/src/models/dataset/Dataset.ts: -------------------------------------------------------------------------------- 1 | import { ChampionData } from "./ChampionData"; 2 | import { deleteChampionRoleDataMatchupSynergyData } from "./ChampionRoleData"; 3 | import { RuneData, RunePathData, StatShardData } from "./RuneData"; 4 | import { ItemData } from "./ItemData"; 5 | import { ratingToWinrate, winrateToRating } from "../../rating/ratings"; 6 | import { SummonerSpellData } from "./SummonerSpellData"; 7 | 8 | export const DATASET_VERSION = "5"; 9 | 10 | export interface Dataset { 11 | version: string; 12 | date: string; 13 | championData: Record; 14 | 15 | itemData: Record; 16 | runeData: Record; 17 | runePathData: Record; 18 | statShardData: Record; 19 | summonerSpellData: Record; 20 | } 21 | 22 | export function deleteDatasetMatchupSynergyData(dataset: Dataset) { 23 | for (const champion of Object.values(dataset.championData)) { 24 | for (const role of Object.values(champion.statsByRole)) { 25 | deleteChampionRoleDataMatchupSynergyData(role); 26 | } 27 | } 28 | } 29 | 30 | export function removeRankBias(dataset: Dataset) { 31 | function getNewWins(wins: number, games: number, rankRating: number) { 32 | return ( 33 | ratingToWinrate(winrateToRating(wins / games) - rankRating) * games 34 | ); 35 | } 36 | 37 | const rankWins = Object.values(dataset.championData).reduce( 38 | (sum, champion) => 39 | sum + 40 | Object.values(champion.statsByRole).reduce( 41 | (sum, stats) => sum + stats.wins, 42 | 0 43 | ), 44 | 0 45 | ); 46 | const rankGames = Object.values(dataset.championData).reduce( 47 | (sum, champion) => 48 | sum + 49 | Object.values(champion.statsByRole).reduce( 50 | (sum, stats) => sum + stats.games, 51 | 0 52 | ), 53 | 0 54 | ); 55 | const rankWinrate = rankWins / rankGames; 56 | const rankRating = winrateToRating(rankWinrate); 57 | 58 | for (const championData of Object.values(dataset.championData)) { 59 | for (const roleData of Object.values(championData.statsByRole)) { 60 | // Fix base winrate 61 | roleData.wins = getNewWins( 62 | roleData.wins, 63 | roleData.games, 64 | rankRating 65 | ); 66 | 67 | // Fix matchups 68 | for (const matchupData of Object.values(roleData.matchup)) { 69 | for (const matchupRoleData of Object.values(matchupData)) { 70 | matchupRoleData.wins = getNewWins( 71 | matchupRoleData.wins, 72 | matchupRoleData.games, 73 | rankRating 74 | ); 75 | } 76 | } 77 | 78 | // Fix duos 79 | for (const duoData of Object.values(roleData.synergy)) { 80 | for (const duoRoleData of Object.values(duoData)) { 81 | duoRoleData.wins = getNewWins( 82 | duoRoleData.wins, 83 | duoRoleData.games, 84 | rankRating 85 | ); 86 | } 87 | } 88 | 89 | // Fix time stats 90 | for (const timeStats of Object.values(roleData.statsByTime)) { 91 | timeStats.wins = getNewWins( 92 | timeStats.wins, 93 | timeStats.games, 94 | rankRating 95 | ); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /packages/core/src/builds/item-analysis.ts: -------------------------------------------------------------------------------- 1 | import { AnalyzeDraftConfig } from "../draft/analysis"; 2 | import { 3 | PartialBuildDataset, 4 | FullBuildDataset, 5 | } from "../models/build/BuildDataset"; 6 | import { EntityAnalysisResult, analyzeEntity } from "./entity-analysis"; 7 | 8 | export type ItemsAnalysisResult = { 9 | boots: Record; 10 | statsByOrder: Record[]; 11 | startingSets: Record; 12 | sets: Record; 13 | }; 14 | 15 | type ItemType = number | "boots" | "startingSets" | "sets"; 16 | 17 | export function analyzeItems( 18 | partialBuildDataset: PartialBuildDataset, 19 | fullBuildDatset: FullBuildDataset, 20 | config: AnalyzeDraftConfig 21 | ): ItemsAnalysisResult { 22 | return { 23 | boots: Object.keys(partialBuildDataset.items.boots).reduce( 24 | (acc, itemId) => { 25 | acc[itemId] = analyzeEntity( 26 | partialBuildDataset, 27 | fullBuildDatset, 28 | config, 29 | getItemStats, 30 | { 31 | type: "boots", 32 | id: parseInt(itemId), 33 | } 34 | ); 35 | return acc; 36 | }, 37 | {} as Record 38 | ), 39 | statsByOrder: partialBuildDataset.items.statsByOrder.map((stats, i) => { 40 | return Object.keys(stats).reduce((acc, itemId) => { 41 | acc[itemId] = analyzeEntity( 42 | partialBuildDataset, 43 | fullBuildDatset, 44 | config, 45 | getItemStats, 46 | { 47 | type: i, 48 | id: parseInt(itemId), 49 | } 50 | ); 51 | return acc; 52 | }, {} as Record); 53 | }), 54 | startingSets: Object.keys( 55 | partialBuildDataset.items.startingSets 56 | ).reduce((acc, setId) => { 57 | acc[setId] = analyzeEntity( 58 | partialBuildDataset, 59 | fullBuildDatset, 60 | config, 61 | getItemStats, 62 | { 63 | type: "startingSets", 64 | id: setId, 65 | } 66 | ); 67 | return acc; 68 | }, {} as Record), 69 | sets: {}, 70 | }; 71 | } 72 | 73 | function getItemStats( 74 | data: PartialBuildDataset, 75 | // order or boots 76 | item: { 77 | id: number | string; 78 | type: ItemType; 79 | } 80 | ) { 81 | switch (item.type) { 82 | case "boots": 83 | return ( 84 | data.items.boots[item.id] ?? { 85 | wins: 0, 86 | games: 0, 87 | } 88 | ); 89 | case "sets": 90 | return ( 91 | data.items.sets[item.id] ?? { 92 | wins: 0, 93 | games: 0, 94 | } 95 | ); 96 | case "startingSets": 97 | return ( 98 | data.items.startingSets[item.id] ?? { 99 | wins: 0, 100 | games: 0, 101 | } 102 | ); 103 | default: 104 | return ( 105 | data.items.statsByOrder[item.type][item.id] ?? { 106 | wins: 0, 107 | games: 0, 108 | } 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "solid-heroicons"; 2 | import { Component, JSX, Show } from "solid-js"; 3 | import toast, { Toast as ToastModel } from "solid-toast"; 4 | 5 | type Props = { 6 | t: ToastModel; 7 | title: string; 8 | content: string; 9 | icon: { 10 | path: JSX.Element; 11 | outline?: boolean; 12 | mini?: boolean; 13 | }; 14 | dismissText?: string; 15 | okText?: string; 16 | onSubmit?: () => void; 17 | }; 18 | 19 | export const Toast: Component = (props) => { 20 | return ( 21 |
26 |
27 |
28 |
29 | 30 |
31 |
32 |

33 | {props.title} 34 |

35 |

36 | {props.content} 37 |

38 | 39 |
40 | 47 | 57 |
58 |
59 |
60 |
61 | 78 |
79 |
80 |
81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog as DialogPrimitives } from "@kobalte/core"; 2 | import { ComponentProps, Show } from "solid-js"; 3 | import { cn } from "../../utils/style"; 4 | import { Icon } from "solid-heroicons"; 5 | import { xMark } from "solid-heroicons/solid"; 6 | 7 | export const Dialog = DialogPrimitives.Root; 8 | 9 | export const DialogTrigger = DialogPrimitives.Trigger; 10 | 11 | function DialogPortal(props: ComponentProps) { 12 | return ( 13 | 14 |
15 | {props.children} 16 |
17 |
18 | ); 19 | } 20 | 21 | function DialogOverlay(props: ComponentProps) { 22 | return ( 23 | 30 | ); 31 | } 32 | 33 | export function DialogContent( 34 | props: ComponentProps & { 35 | canClose?: boolean; 36 | } 37 | ) { 38 | return ( 39 | 40 | 41 | 48 | {props.children} 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export function DialogHeader(props: ComponentProps<"div">) { 60 | return ( 61 |
68 | {props.children} 69 |
70 | ); 71 | } 72 | 73 | export function DialogFooter(props: ComponentProps<"div">) { 74 | return ( 75 |
82 | {props.children} 83 |
84 | ); 85 | } 86 | 87 | export function DialogTitle( 88 | props: ComponentProps 89 | ) { 90 | return ( 91 | 98 | {props.children} 99 | 100 | ); 101 | } 102 | 103 | export function DialogDescription( 104 | props: ComponentProps 105 | ) { 106 | return ( 107 | 111 | {props.children} 112 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/SummonerSpellsStats.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "solid-js"; 2 | import { Panel, PanelHeader } from "../../common/Panel"; 3 | import { HorizontalEntityStats } from "./EntityStats"; 4 | import { useBuild } from "../../../contexts/BuildContext"; 5 | import { ratingToWinrate } from "@draftgap/core/src/rating/ratings"; 6 | import { useDataset } from "../../../contexts/DatasetContext"; 7 | import { getRatingClass, formatPercentage } from "../../../utils/rating"; 8 | 9 | export const SummonerSpellsStats: Component = () => { 10 | const { buildAnalysisResult, partialBuildDataset } = useBuild(); 11 | return ( 12 | 13 | Summoner Spells 14 | 17 | partialBuildDataset()!.summonerSpells[id].games / 18 | partialBuildDataset()!.games > 19 | 0.01 20 | )} 21 | getGames={(id) => 22 | partialBuildDataset()!.summonerSpells[id].games 23 | } 24 | getRating={(id) => 25 | buildAnalysisResult()!.summonerSpells[id].totalRating 26 | } 27 | > 28 | {([id]) => } 29 | 30 | 31 | ); 32 | }; 33 | 34 | const SummonerSpells: Component<{ setId: string }> = (props) => { 35 | const { dataset } = useDataset(); 36 | const { partialBuildDataset, buildAnalysisResult, setSelectedEntity } = 37 | useBuild(); 38 | 39 | const result = () => buildAnalysisResult()!.summonerSpells[props.setId]; 40 | const data = () => partialBuildDataset()!.summonerSpells[props.setId]; 41 | 42 | const spells = () => 43 | props.setId 44 | .split("_") 45 | .map((id) => parseInt(id)) 46 | .sort((a, b) => { 47 | // Natural number sort except 4 (flash) is before all 48 | if (a === 4) return -1; 49 | if (b === 4) return 1; 50 | return a - b; 51 | }); 52 | 53 | return ( 54 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | workflow_dispatch: 7 | 8 | defaults: 9 | run: 10 | working-directory: apps/frontend 11 | 12 | jobs: 13 | release: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | platform: [macos-latest, windows-latest] # ubuntu-20.04, 18 | runs-on: ${{ matrix.platform }} 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | # - name: Install dependencies (ubuntu only) 24 | # if: matrix.platform == 'ubuntu-20.04' 25 | # # You can remove libayatana-appindicator3-dev if you don't use the system tray feature. 26 | # run: | 27 | # sudo apt-get update 28 | # sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev 29 | 30 | - name: Rust setup 31 | uses: dtolnay/rust-toolchain@stable 32 | 33 | - name: Rust cache 34 | uses: swatinem/rust-cache@v2 35 | with: 36 | workspaces: "./src-tauri -> target" 37 | 38 | - name: Install PNPM 39 | run: npm install -g pnpm 40 | 41 | - name: Sync node version and setup cache 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: "lts/*" 45 | cache: "pnpm" # Set this to npm, yarn or pnpm. 46 | 47 | - name: Install app dependencies 48 | # Remove `&& yarn build` if you build your frontend in `beforeBuildCommand` 49 | run: pnpm install # Change this to npm, yarn or pnpm. 50 | 51 | - name: Build the app 52 | uses: tauri-apps/tauri-action@v0 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 56 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 57 | VITE_GA_TAG: "G-967XTGK665" 58 | with: 59 | tagName: ${{ github.ref_name }} # This only works if your workflow triggers on new tags. 60 | releaseName: "DraftGap v__VERSION__" # tauri-action replaces \_\_VERSION\_\_ with the app version. 61 | releaseBody: "# Download DraftGap\n[Windows](https://github.com/vigovlugt/draftgap/releases/download/v__VERSION__/DraftGap___VERSION___x64_en-US.msi)\n[Mac](https://github.com/vigovlugt/draftgap/releases/download/v__VERSION__/DraftGap.app.tar.gz)" 62 | releaseDraft: false 63 | prerelease: false 64 | 65 | publish: 66 | runs-on: ubuntu-20.04 67 | needs: [release] 68 | permissions: write-all 69 | steps: 70 | - name: Checkout repository 71 | uses: actions/checkout@v3 72 | 73 | - name: Install PNPM 74 | run: npm install -g pnpm 75 | 76 | - name: Sync node version and setup cache 77 | uses: actions/setup-node@v3 78 | with: 79 | node-version: "lts/*" 80 | cache: "pnpm" # Set this to npm, yarn or pnpm. 81 | 82 | - name: Install app dependencies 83 | run: pnpm install 84 | 85 | - name: Update tauri-update gist 86 | run: pnpm publish-tauri-update 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | GIST_TOKEN: ${{ secrets.GIST_TOKEN }} 90 | S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} 91 | S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} 92 | S3_ENDPOINT: https://6239efaaf63b138409f3ff6e44602435.r2.cloudflarestorage.com 93 | S3_PUBLIC_URL: https://bucket.draftgap.com 94 | GIST_ID: "5d549f4fdd602eb22542ef55e7c881ec" 95 | REPOSITORY_OWNER: vigovlugt 96 | REPOSITORY_NAME: draftgap 97 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/ItemStats.tsx: -------------------------------------------------------------------------------- 1 | import { Component, For } from "solid-js"; 2 | import { Panel, PanelHeader } from "../../common/Panel"; 3 | import { VerticalEntityStats } from "./EntityStats"; 4 | import { useBuild } from "../../../contexts/BuildContext"; 5 | import { ratingToWinrate } from "@draftgap/core/src/rating/ratings"; 6 | import { getRatingClass, formatPercentage } from "../../../utils/rating"; 7 | import { useDataset } from "../../../contexts/DatasetContext"; 8 | 9 | const ZERO_TO_FOUR = [0, 1, 2, 3, 4] as const; 10 | 11 | export const ItemStats: Component = () => { 12 | const { buildAnalysisResult, partialBuildDataset } = useBuild(); 13 | 14 | const getDataForOrder = (order: number) => { 15 | const orderGames = Object.values( 16 | partialBuildDataset()!.items.statsByOrder[order] 17 | ).reduce((acc, item) => acc + item.games, 0); 18 | return Object.keys( 19 | buildAnalysisResult()!.items.statsByOrder[order] ?? {} 20 | ) 21 | .map((id) => parseInt(id)) 22 | .filter( 23 | (id) => 24 | partialBuildDataset()!.items.statsByOrder[order][id].games / 25 | orderGames > 26 | 0.01 27 | ); 28 | }; 29 | 30 | return ( 31 | 32 | Items 33 |
34 | 35 | {(i) => ( 36 |
37 |

38 | Item {i + 1} 39 |

40 | 43 | partialBuildDataset()!.items.statsByOrder[ 44 | i 45 | ][id].games 46 | } 47 | getRating={(id) => 48 | buildAnalysisResult()!.items.statsByOrder[ 49 | i 50 | ][id].totalRating 51 | } 52 | > 53 | {([item]) => } 54 | 55 |
56 | )} 57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | const Item: Component<{ order: number; itemId: number }> = (props) => { 64 | const { dataset } = useDataset(); 65 | const { partialBuildDataset, buildAnalysisResult, setSelectedEntity } = 66 | useBuild(); 67 | 68 | const result = () => 69 | buildAnalysisResult()!.items.statsByOrder[props.order][props.itemId]; 70 | const data = () => 71 | partialBuildDataset()!.items.statsByOrder[props.order][props.itemId]; 72 | 73 | return ( 74 | 76 | setSelectedEntity({ 77 | type: "item", 78 | itemType: props.order, 79 | id: props.itemId, 80 | }) 81 | } 82 | > 83 | 84 | 90 | 91 | 92 | {formatPercentage(ratingToWinrate(result().totalRating))} 93 | 94 | 95 | {formatPercentage(data().games / partialBuildDataset()!.games)} 96 | 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /apps/frontend/scripts/publish-tauri-update.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from "@octokit/rest"; 2 | import { config } from "dotenv"; 3 | import { 4 | PutObjectCommand, 5 | PutObjectCommandInput, 6 | S3Client, 7 | } from "@aws-sdk/client-s3"; 8 | config(); 9 | 10 | const errors: string[] = []; 11 | 12 | const [ 13 | GITHUB_TOKEN, 14 | GIST_TOKEN, 15 | GIST_ID, 16 | REPOSITORY_NAME, 17 | REPOSITORY_OWNER, 18 | S3_ACCESS_KEY_ID, 19 | S3_SECRET_ACCESS_KEY, 20 | S3_ENDPOINT, 21 | S3_PUBLIC_URL, 22 | ] = ( 23 | [ 24 | "GITHUB_TOKEN", 25 | "GIST_TOKEN", 26 | "GIST_ID", 27 | "REPOSITORY_NAME", 28 | "REPOSITORY_OWNER", 29 | "S3_ACCESS_KEY_ID", 30 | "S3_SECRET_ACCESS_KEY", 31 | "S3_ENDPOINT", 32 | "S3_PUBLIC_URL", 33 | ] as const 34 | ).map((v) => { 35 | const value = process.env[v]; 36 | if (!value) { 37 | errors.push(`${v} is not set`); 38 | } 39 | return value ?? ""; 40 | }); 41 | 42 | const S3_BUCKET = process.env.S3_BUCKET || "draftgap"; 43 | 44 | if (errors.length > 0) { 45 | console.error(errors.join("\n")); 46 | process.exit(1); 47 | } 48 | 49 | const octokit = new Octokit({ 50 | auth: GITHUB_TOKEN, 51 | }); 52 | 53 | // Api token is stored in the environment variable 54 | export const client = new S3Client({ 55 | endpoint: S3_ENDPOINT, 56 | region: process.env.S3_REGION || "auto", 57 | credentials: { 58 | accessKeyId: S3_ACCESS_KEY_ID, 59 | secretAccessKey: S3_SECRET_ACCESS_KEY, 60 | }, 61 | }); 62 | 63 | export async function main() { 64 | const latestRelease = await octokit.repos.getLatestRelease({ 65 | owner: REPOSITORY_OWNER, 66 | repo: REPOSITORY_NAME, 67 | }); 68 | 69 | const latestJsonBinary = await octokit.repos.getReleaseAsset({ 70 | owner: REPOSITORY_OWNER, 71 | repo: REPOSITORY_NAME, 72 | asset_id: latestRelease.data.assets.find( 73 | (a) => a.name === "latest.json" 74 | )!.id, 75 | headers: { 76 | Accept: "application/octet-stream", 77 | }, 78 | }); 79 | 80 | const latestJsonString = new TextDecoder().decode( 81 | latestJsonBinary.data as unknown as ArrayBuffer 82 | ); 83 | 84 | const latestJson = JSON.parse(latestJsonString); 85 | 86 | await storeReleaseAssetsInS3(latestRelease, latestJson); 87 | 88 | for (const platform of Object.values(latestJson.platforms) as any) { 89 | const url = new URL(platform.url); 90 | const fileName = url.pathname 91 | .split("/") 92 | .at(-1)! 93 | .replace(latestJson.version, "latest"); 94 | platform.url = `${S3_PUBLIC_URL}/releases/${fileName}`; 95 | } 96 | 97 | const gistOctokit = new Octokit({ 98 | auth: GIST_TOKEN, 99 | }); 100 | await gistOctokit.gists.update({ 101 | gist_id: GIST_ID, 102 | files: { 103 | "draftgap-tauri-update.json": { 104 | content: JSON.stringify(latestJson, null, 4), 105 | }, 106 | }, 107 | }); 108 | 109 | console.log("Updated tauri update json"); 110 | } 111 | 112 | export async function storeReleaseAssetsInS3( 113 | release: Awaited>, 114 | latestJson: any 115 | ) { 116 | for (const asset of release.data.assets) { 117 | console.log(`Storing ${asset.name} in S3`); 118 | const res = await octokit.repos.getReleaseAsset({ 119 | owner: REPOSITORY_OWNER, 120 | repo: REPOSITORY_NAME, 121 | asset_id: asset.id, 122 | headers: { 123 | Accept: "application/octet-stream", 124 | }, 125 | }); 126 | 127 | const assetBinary = res.data as unknown as ArrayBuffer; 128 | const assetName = asset.name.replace(latestJson.version, "latest"); 129 | 130 | const params = { 131 | Bucket: S3_BUCKET, 132 | Key: `releases/${assetName}`, 133 | Body: new Uint8Array(assetBinary), 134 | ContentType: asset.content_type, 135 | } satisfies PutObjectCommandInput; 136 | 137 | const command = new PutObjectCommand(params); 138 | await client.send(command); 139 | } 140 | } 141 | 142 | main(); 143 | -------------------------------------------------------------------------------- /apps/frontend/src/components/views/builds/StarterItemStats.tsx: -------------------------------------------------------------------------------- 1 | import { Component, For, Show } from "solid-js"; 2 | import { Panel, PanelHeader } from "../../common/Panel"; 3 | import { HorizontalEntityStats } from "./EntityStats"; 4 | import { useBuild } from "../../../contexts/BuildContext"; 5 | import { ratingToWinrate } from "@draftgap/core/src/rating/ratings"; 6 | import { getRatingClass, formatPercentage } from "../../../utils/rating"; 7 | import { useDataset } from "../../../contexts/DatasetContext"; 8 | 9 | export const StarterItemStats: Component = () => { 10 | const { buildAnalysisResult, partialBuildDataset } = useBuild(); 11 | 12 | return ( 13 | 14 | Starting Items 15 | 20 | partialBuildDataset()!.items.startingSets[id].games / 21 | partialBuildDataset()!.games > 22 | 0.01 23 | )} 24 | getGames={(id) => 25 | partialBuildDataset()!.items.startingSets[id].games 26 | } 27 | getRating={(id) => 28 | buildAnalysisResult()!.items.startingSets[id].totalRating 29 | } 30 | > 31 | {([id]) => } 32 | 33 | 34 | ); 35 | }; 36 | 37 | const StarterItem: Component<{ setId: string }> = (props) => { 38 | const { dataset } = useDataset(); 39 | const { partialBuildDataset, buildAnalysisResult, setSelectedEntity } = 40 | useBuild(); 41 | 42 | const result = () => buildAnalysisResult()!.items.startingSets[props.setId]; 43 | const data = () => partialBuildDataset()!.items.startingSets[props.setId]; 44 | 45 | const items = () => 46 | props.setId 47 | .split("_") 48 | .map((id) => parseInt(id)) 49 | .reduce((acc, id) => { 50 | if (acc[id] !== undefined) { 51 | acc[id] += 1; 52 | } else { 53 | acc[id] = 1; 54 | } 55 | 56 | return acc; 57 | }, {} as Record); 58 | 59 | return ( 60 | 111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /apps/frontend/src/types/Lcu.ts: -------------------------------------------------------------------------------- 1 | export type LolChampSelectChampSelectSession = { 2 | actions: LolChampSelectChampSelectAction[][]; 3 | allowBattleBoost: boolean; 4 | allowDuplicatePicks: boolean; 5 | allowLockedEvents: boolean; 6 | allowSkinSelection: boolean; 7 | bans: LolChampSelectChampSelectBans; 8 | benchChampionIds: number[]; 9 | benchEnabled: boolean; 10 | boostableSkinCount: number; 11 | chatDetails: LolChampSelectChampSelectChatRoomDetails; 12 | counter: number; 13 | entitledFeatureState: LolChampSelectChampSelectEntitledFeatureState; 14 | gameId: number; 15 | hasSimultaneousBans: boolean; 16 | hasSimultaneousPicks: boolean; 17 | isCustomGame: boolean; 18 | isSpectating: boolean; 19 | localPlayerCellId: number; 20 | lockedEventIndex: number; 21 | myTeam: LolChampSelectChampSelectPlayerSelection[]; 22 | recoveryCounter: number; 23 | rerollsRemaining: number; 24 | skipChampionSelect: boolean; 25 | theirTeam: LolChampSelectChampSelectPlayerSelection[]; 26 | timer: LolChampSelectChampSelectTimer; 27 | trades: LolChampSelectChampSelectTradeContract[]; 28 | }; 29 | 30 | export type LolChampSelectChampSelectAction = { 31 | actorCellId: number; 32 | championId: number; 33 | completed: boolean; 34 | id: number; 35 | isAllyAction: boolean; 36 | isInProgress: boolean; 37 | pickTurn: number; 38 | type: string; 39 | }; 40 | 41 | export type LolChampSelectChampSelectBans = { 42 | myTeamBans: number[]; 43 | numBans: number; 44 | theirTeamBans: number[]; 45 | }; 46 | 47 | export type LolChampSelectChampSelectChatRoomDetails = { 48 | chatRoomName: string; 49 | chatRoomPassword: string; 50 | }; 51 | 52 | export type LolChampSelectChampSelectEntitledFeatureState = { 53 | additionalRerolls: number; 54 | unlockedSkinIds: number[]; 55 | }; 56 | 57 | export type LolChampSelectChampSelectPlayerSelection = { 58 | assignedPosition: string; 59 | cellId: number; 60 | championId: number; 61 | championPickIntent: number; 62 | entitledFeatureType: string; 63 | nameVisibilityType: string; 64 | obfuscatedPuuid: string; 65 | obfuscatedSummonerId: number; 66 | puuid: string; 67 | selectedSkinId: number; 68 | spell1Id: number; 69 | spell2Id: number; 70 | summonerId: number; 71 | team: number; 72 | wardSkinId: number; 73 | }; 74 | 75 | export type LolChampSelectChampSelectTimer = { 76 | adjustedTimeLeftInPhase: number; 77 | internalNowInEpochMs: number; 78 | isInfinite: boolean; 79 | phase: string; 80 | totalTimeInPhase: number; 81 | }; 82 | 83 | export type LolChampSelectChampSelectTradeContract = { 84 | cellId: number; 85 | id: number; 86 | state: 87 | | "AVAILABLE" 88 | | "BUSY" 89 | | "INVALID" 90 | | "RECEIVED" 91 | | "SENT" 92 | | "DECLINED" 93 | | "CANCELLED" 94 | | "ACCEPTED"; 95 | }; 96 | 97 | // lol-summoner/v1/current-summoner 98 | 99 | export type LolSummonerSummoner = { 100 | accountId: string; 101 | displayName: string; 102 | internalName: string; 103 | nameChangeFlag: boolean; 104 | percentCompleteForNextLevel: number; 105 | privacy: string; 106 | profileIconId: number; 107 | puuid: string; 108 | rerollPoints: LolSummonerRerollPoints; 109 | summonerId: number; 110 | summonerLevel: number; 111 | xpSinceLastLevel: number; 112 | xpUntilNextLevel: number; 113 | }; 114 | 115 | export type LolSummonerRerollPoints = { 116 | currentPoints: number; 117 | maxRolls: number; 118 | numberOfRolls: number; 119 | pointsCostToRoll: number; 120 | pointsToReroll: number; 121 | }; 122 | 123 | // lol-champ-select/v1/all-grid-champions 124 | 125 | export type LolChampSelectGridChampion = { 126 | disabled: boolean; 127 | freeToPlay: boolean; 128 | freeToPlayForQueue: boolean; 129 | freeToPlayReward: boolean; 130 | id: number; 131 | masteryChestGranted: boolean; 132 | masteryLevel: number; 133 | masteryPoints: number; 134 | name: string; 135 | owned: boolean; 136 | positionsFavorited: string[]; 137 | rented: boolean; 138 | roles: string[]; 139 | selectionStatus: { 140 | banIntended: boolean; 141 | banIntededByMe: boolean; 142 | isBanned: boolean; 143 | pickIntented: boolean; 144 | pickIntentedByMe: boolean; 145 | pickIntentedPosition: number; 146 | pickedByOtherOrBanned: boolean; 147 | selectedByMe: boolean; 148 | }; 149 | squarePortraitPath: string; 150 | }; 151 | 152 | export type LolChampSelectGridChampions = LolChampSelectGridChampion[]; 153 | -------------------------------------------------------------------------------- /apps/frontend/src/components/dialogs/FAQDialog.tsx: -------------------------------------------------------------------------------- 1 | import { DialogContent, DialogHeader, DialogTitle } from "../common/Dialog"; 2 | 3 | export function FAQDialog() { 4 | return ( 5 | 6 | 7 | FAQ 8 | 9 |
10 |

What is DraftGap?

11 |

12 | DraftGap is a tool to help you pick the best champion for 13 | each situation. In the table, you can see all possible 14 | champions to pick, and their winrates when picked in this 15 | teamcomp. 16 |

17 |
18 | 19 |
20 |

How does this work?

21 |

22 | DraftGap analyzes a couple of statistics, the base winrates 23 | of champions, the winrates of duo's within each team and 24 | every matchup between the two teams. It calulates the 25 | winrate of each team comp after picking a possible champion, 26 | and shows this in the table. This method is based of the 27 | work of{" "} 28 | 33 | Jayensee 34 | {" "} 35 | and is explained in his{" "} 36 | 41 | great video 42 | 43 | . 44 |

45 |
46 | 47 |
48 |

49 | Does DraftGap have any shortcomings? 50 |

51 |

52 | DraftGap is not perfect, and there are several things to 53 | keep in mind. The overall team comp identity is not taken 54 | into account. The synergy of duos within a team are used in 55 | the calculations, but the tool does not know about team comp 56 | identity like 'engage' or 'poke'. Damage composition is also 57 | not used in the calculation (but it is shown, above the team 58 | winrate), so you need to keep this in mind on you own. 59 |
60 | These shortcomings result from the fact that there is not 61 | enough data to make a perfect prediction. And we do not want 62 | to incorporate opinions like 'malphite is an engage 63 | champion' into the tool, as using just data is the most 64 | objective way to make a decision. 65 |

66 |
67 | 68 |
69 |

Where is the data from?

70 |

71 | The data used are the current Plat+{" "} 72 | 77 | Lolalytics 78 | {" "} 79 | solo/duo winrates from all regions of the last 30 days. The 80 | data is updated every 24 hours. 81 |

82 |
83 | 84 |
85 |

86 | How does the risk level work? 87 |

88 |

89 | The risk level is a measure of how much to trust small 90 | sample sizes in the data. The higher the risk level, the 91 | more it will recommend duos/matchups that have a small 92 | sample size, and thus could be inaccurate. On the other 93 | hand, it will also recommend more niche duos/counters that 94 | are not seen often, but could be very strong. 95 |

96 |
97 | 98 |

99 | If you have any other questions, feedback, bug reports or 100 | feature requests, feel free to send an email to:{" "} 101 | 106 | vigovlugt+draftgap@gmail.com 107 | 108 |

109 |
110 | ); 111 | } 112 | --------------------------------------------------------------------------------