Created
August 14, 2026 15:56
-
-
Save EncodeTheCode/f4ab506a0df5aee5e462b9d94cc1d215 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta | |
| name="viewport" | |
| content="width=device-width, initial-scale=1.0" | |
| > | |
| <title>CS2 Tournament Bracket</title> | |
| <style> | |
| /* ========================================================= | |
| THEME | |
| ========================================================= */ | |
| :root { | |
| --bg: | |
| #080a0d; | |
| --bg-soft: | |
| #0d1115; | |
| --panel: | |
| #11161b; | |
| --panel-2: | |
| #171d23; | |
| --panel-3: | |
| #1c232a; | |
| --border: | |
| #2c353e; | |
| --border-soft: | |
| rgba(255,255,255,.055); | |
| --text: | |
| #e7ebee; | |
| --muted: | |
| #7f8992; | |
| --muted-2: | |
| #626b73; | |
| --accent: | |
| #d6a84f; | |
| --accent-soft: | |
| rgba(214,168,79,.16); | |
| --winner-bg: | |
| #b9ddc0; | |
| --winner-bg-2: | |
| #a7d0ae; | |
| --winner-text: | |
| #18351e; | |
| --eliminated-bg: | |
| #686d71; | |
| --eliminated-bg-2: | |
| #565b5f; | |
| --eliminated-text: | |
| #363a3d; | |
| --card-width: | |
| 250px; | |
| --round-gap: | |
| 100px; | |
| --match-gap: | |
| 28px; | |
| --shadow: | |
| 0 15px 40px rgba(0,0,0,.42); | |
| --emboss: | |
| 1px 1px 0 rgba(255,255,255,.10), | |
| -1px -1px 0 rgba(0,0,0,.50); | |
| } | |
| /* ========================================================= | |
| RESET | |
| ========================================================= */ | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html { | |
| min-height: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| color: | |
| var(--text); | |
| background: | |
| radial-gradient( | |
| circle at 50% -10%, | |
| rgba(214,168,79,.10), | |
| transparent 35% | |
| ), | |
| radial-gradient( | |
| circle at 10% 100%, | |
| rgba(255,255,255,.025), | |
| transparent 30% | |
| ), | |
| var(--bg); | |
| font-family: | |
| Inter, | |
| ui-sans-serif, | |
| system-ui, | |
| -apple-system, | |
| BlinkMacSystemFont, | |
| "Segoe UI", | |
| sans-serif; | |
| } | |
| /* ========================================================= | |
| APPLICATION | |
| ========================================================= */ | |
| .app { | |
| min-height: | |
| 100vh; | |
| padding: | |
| 28px | |
| 28px | |
| 50px; | |
| } | |
| /* ========================================================= | |
| HEADER | |
| ========================================================= */ | |
| .header { | |
| max-width: | |
| 1900px; | |
| margin: | |
| 0 auto 22px; | |
| display: | |
| flex; | |
| justify-content: | |
| space-between; | |
| align-items: | |
| flex-end; | |
| gap: | |
| 30px; | |
| } | |
| .brand { | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| gap: | |
| 7px; | |
| } | |
| .brand-eyebrow { | |
| color: | |
| var(--accent); | |
| font-size: | |
| 10px; | |
| font-weight: | |
| 900; | |
| text-transform: | |
| uppercase; | |
| letter-spacing: | |
| .18em; | |
| } | |
| .brand h1 { | |
| margin: | |
| 0; | |
| font-size: | |
| 29px; | |
| line-height: | |
| 1; | |
| font-weight: | |
| 900; | |
| letter-spacing: | |
| -.025em; | |
| text-transform: | |
| uppercase; | |
| } | |
| .brand p { | |
| margin: | |
| 0; | |
| color: | |
| var(--muted); | |
| font-size: | |
| 12px; | |
| font-weight: | |
| 600; | |
| } | |
| /* ========================================================= | |
| CONTROLS | |
| ========================================================= */ | |
| .controls { | |
| display: | |
| flex; | |
| align-items: | |
| center; | |
| gap: | |
| 7px; | |
| flex-wrap: | |
| wrap; | |
| } | |
| .control-label { | |
| color: | |
| var(--muted); | |
| font-size: | |
| 9px; | |
| font-weight: | |
| 900; | |
| text-transform: | |
| uppercase; | |
| letter-spacing: | |
| .12em; | |
| margin-right: | |
| 3px; | |
| } | |
| .layout-button { | |
| appearance: | |
| none; | |
| border: | |
| 1px solid var(--border); | |
| background: | |
| var(--panel); | |
| color: | |
| var(--muted); | |
| padding: | |
| 9px 13px; | |
| border-radius: | |
| 3px; | |
| font-size: | |
| 10px; | |
| font-weight: | |
| 900; | |
| text-transform: | |
| uppercase; | |
| letter-spacing: | |
| .08em; | |
| cursor: | |
| pointer; | |
| transition: | |
| background .15s ease, | |
| color .15s ease, | |
| border-color .15s ease, | |
| transform .15s ease; | |
| } | |
| .layout-button:hover { | |
| color: | |
| var(--text); | |
| border-color: | |
| #56616b; | |
| transform: | |
| translateY(-1px); | |
| } | |
| .layout-button.active { | |
| color: | |
| #18140d; | |
| background: | |
| var(--accent); | |
| border-color: | |
| var(--accent); | |
| } | |
| /* ========================================================= | |
| MODE INDICATOR | |
| ========================================================= */ | |
| .mode-indicator { | |
| margin-left: | |
| 10px; | |
| display: | |
| inline-flex; | |
| align-items: | |
| center; | |
| gap: | |
| 7px; | |
| padding: | |
| 7px 10px; | |
| border: | |
| 1px solid var(--border); | |
| background: | |
| var(--panel); | |
| color: | |
| var(--muted); | |
| font-size: | |
| 9px; | |
| font-weight: | |
| 900; | |
| letter-spacing: | |
| .08em; | |
| text-transform: | |
| uppercase; | |
| } | |
| .mode-dot { | |
| width: | |
| 6px; | |
| height: | |
| 6px; | |
| border-radius: | |
| 50%; | |
| background: | |
| #6f7880; | |
| } | |
| .mode-indicator.live .mode-dot { | |
| background: | |
| #7dcc8b; | |
| box-shadow: | |
| 0 0 8px rgba(125,204,139,.55); | |
| } | |
| /* ========================================================= | |
| BRACKET CONTAINER | |
| ========================================================= */ | |
| .bracket-shell { | |
| max-width: | |
| 1900px; | |
| margin: | |
| 0 auto; | |
| min-height: | |
| 720px; | |
| overflow: | |
| auto; | |
| border: | |
| 1px solid var(--border); | |
| background: | |
| linear-gradient( | |
| 135deg, | |
| rgba(255,255,255,.018), | |
| transparent 40% | |
| ), | |
| var(--bg-soft); | |
| box-shadow: | |
| var(--shadow); | |
| } | |
| .bracket { | |
| min-width: | |
| max-content; | |
| min-height: | |
| 720px; | |
| padding: | |
| 65px 70px; | |
| } | |
| /* ========================================================= | |
| ROUND | |
| ========================================================= */ | |
| .round { | |
| width: | |
| var(--card-width); | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| } | |
| .round-title { | |
| height: | |
| 35px; | |
| display: | |
| flex; | |
| align-items: | |
| center; | |
| color: | |
| var(--muted); | |
| border-bottom: | |
| 1px solid var(--border); | |
| margin-bottom: | |
| 25px; | |
| font-size: | |
| 10px; | |
| font-weight: | |
| 900; | |
| text-transform: | |
| uppercase; | |
| letter-spacing: | |
| .13em; | |
| } | |
| .round-matches { | |
| flex: | |
| 1; | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| justify-content: | |
| space-around; | |
| gap: | |
| var(--match-gap); | |
| } | |
| /* ========================================================= | |
| CLASSIC LAYOUT | |
| ========================================================= */ | |
| .bracket.classic { | |
| display: | |
| flex; | |
| align-items: | |
| stretch; | |
| gap: | |
| var(--round-gap); | |
| } | |
| /* ========================================================= | |
| MATCH | |
| ========================================================= */ | |
| .match { | |
| width: | |
| var(--card-width); | |
| position: | |
| relative; | |
| overflow: | |
| hidden; | |
| background: | |
| linear-gradient( | |
| 180deg, | |
| rgba(255,255,255,.035), | |
| rgba(255,255,255,.008) | |
| ), | |
| var(--panel); | |
| border: | |
| 1px solid var(--border); | |
| border-radius: | |
| 3px; | |
| box-shadow: | |
| 0 7px 20px rgba(0,0,0,.25); | |
| transition: | |
| border-color .15s ease, | |
| box-shadow .15s ease, | |
| transform .15s ease; | |
| } | |
| .match:hover { | |
| border-color: | |
| rgba(255,255,255,.18); | |
| box-shadow: | |
| 0 12px 30px rgba(0,0,0,.38); | |
| } | |
| .match.interactive:hover { | |
| transform: | |
| translateY(-2px); | |
| } | |
| /* ========================================================= | |
| MATCH HEADER | |
| ========================================================= */ | |
| .match-header { | |
| height: | |
| 28px; | |
| display: | |
| flex; | |
| justify-content: | |
| space-between; | |
| align-items: | |
| center; | |
| padding: | |
| 0 10px; | |
| background: | |
| rgba(255,255,255,.025); | |
| border-bottom: | |
| 1px solid var(--border-soft); | |
| color: | |
| var(--muted); | |
| font-size: | |
| 9px; | |
| font-weight: | |
| 900; | |
| text-transform: | |
| uppercase; | |
| letter-spacing: | |
| .08em; | |
| } | |
| .match-header .series { | |
| color: | |
| var(--muted-2); | |
| } | |
| .match-header .match-id { | |
| color: | |
| var(--muted); | |
| } | |
| /* ========================================================= | |
| TEAM | |
| ========================================================= */ | |
| .team { | |
| min-height: | |
| 34px; | |
| display: | |
| grid; | |
| grid-template-columns: | |
| 29px | |
| minmax(0,1fr) | |
| 32px; | |
| align-items: | |
| center; | |
| gap: | |
| 7px; | |
| padding: | |
| 0 10px; | |
| border-bottom: | |
| 1px solid var(--border-soft); | |
| cursor: | |
| pointer; | |
| transition: | |
| background .15s ease, | |
| filter .15s ease; | |
| } | |
| .team:last-child { | |
| border-bottom: | |
| 0; | |
| } | |
| .team:hover { | |
| background: | |
| rgba(255,255,255,.035); | |
| } | |
| .team.readonly { | |
| cursor: | |
| default; | |
| } | |
| .team.readonly:hover { | |
| background: | |
| inherit; | |
| } | |
| /* ========================================================= | |
| LOGO | |
| ========================================================= */ | |
| .team-logo { | |
| width: | |
| 22px; | |
| height: | |
| 22px; | |
| display: | |
| flex; | |
| align-items: | |
| center; | |
| justify-content: | |
| center; | |
| overflow: | |
| hidden; | |
| border: | |
| 1px solid #3a424b; | |
| border-radius: | |
| 2px; | |
| background: | |
| #222930; | |
| color: | |
| #d7dde2; | |
| font-size: | |
| 8px; | |
| font-weight: | |
| 900; | |
| } | |
| .team-logo img { | |
| width: | |
| 100%; | |
| height: | |
| 100%; | |
| object-fit: | |
| contain; | |
| } | |
| /* ========================================================= | |
| TEAM NAME | |
| ========================================================= */ | |
| .team-name { | |
| min-width: | |
| 0; | |
| overflow: | |
| hidden; | |
| white-space: | |
| nowrap; | |
| text-overflow: | |
| ellipsis; | |
| font-size: | |
| 12px; | |
| font-weight: | |
| 800; | |
| letter-spacing: | |
| .01em; | |
| } | |
| .team-seed { | |
| margin-right: | |
| 4px; | |
| color: | |
| var(--muted); | |
| font-size: | |
| 9px; | |
| font-weight: | |
| 700; | |
| } | |
| .score { | |
| text-align: | |
| right; | |
| font-size: | |
| 13px; | |
| font-weight: | |
| 950; | |
| } | |
| /* ========================================================= | |
| WINNER | |
| ========================================================= */ | |
| .team.winner { | |
| background: | |
| linear-gradient( | |
| 90deg, | |
| var(--winner-bg), | |
| var(--winner-bg-2) | |
| ); | |
| color: | |
| var(--winner-text); | |
| } | |
| .team.winner .team-seed { | |
| color: | |
| #42614a; | |
| } | |
| .team.winner .team-logo { | |
| background: | |
| rgba(255,255,255,.35); | |
| border-color: | |
| rgba(0,0,0,.13); | |
| } | |
| /* ========================================================= | |
| ELIMINATED | |
| ========================================================= */ | |
| .team.eliminated { | |
| background: | |
| linear-gradient( | |
| 180deg, | |
| var(--eliminated-bg), | |
| var(--eliminated-bg-2) | |
| ); | |
| color: | |
| var(--eliminated-text); | |
| text-decoration: | |
| line-through; | |
| text-decoration-thickness: | |
| 1px; | |
| text-decoration-color: | |
| rgba(40,43,45,.85); | |
| text-shadow: | |
| 1px 1px 0 rgba(255,255,255,.12), | |
| -1px -1px 0 rgba(0,0,0,.55); | |
| filter: | |
| saturate(.45); | |
| } | |
| .team.eliminated .team-logo { | |
| filter: | |
| grayscale(1); | |
| opacity: | |
| .62; | |
| } | |
| .team.eliminated .team-seed { | |
| color: | |
| #474b4e; | |
| } | |
| .team.eliminated .score { | |
| color: | |
| #414548; | |
| } | |
| /* ========================================================= | |
| TBD | |
| ========================================================= */ | |
| .team.tbd { | |
| color: | |
| var(--muted-2); | |
| } | |
| .team.tbd .team-logo { | |
| color: | |
| #596169; | |
| border-color: | |
| #303840; | |
| } | |
| /* ========================================================= | |
| FINAL | |
| ========================================================= */ | |
| .match.final-match { | |
| border-color: | |
| var(--accent); | |
| box-shadow: | |
| 0 0 0 1px | |
| rgba(214,168,79,.10), | |
| 0 18px 50px | |
| rgba(0,0,0,.48); | |
| } | |
| .final-match .match-header { | |
| color: | |
| var(--accent); | |
| background: | |
| linear-gradient( | |
| 90deg, | |
| var(--accent-soft), | |
| transparent | |
| ); | |
| } | |
| .final-match .match-header .series { | |
| color: | |
| var(--accent); | |
| } | |
| /* ========================================================= | |
| CHAMPION | |
| ========================================================= */ | |
| .champion { | |
| padding: | |
| 12px; | |
| text-align: | |
| center; | |
| background: | |
| linear-gradient( | |
| 180deg, | |
| rgba(214,168,79,.20), | |
| rgba(214,168,79,.035) | |
| ); | |
| border-top: | |
| 1px solid var(--accent); | |
| color: | |
| var(--accent); | |
| font-size: | |
| 9px; | |
| font-weight: | |
| 950; | |
| text-transform: | |
| uppercase; | |
| letter-spacing: | |
| .16em; | |
| } | |
| .champion-name { | |
| display: | |
| block; | |
| margin-top: | |
| 5px; | |
| color: | |
| #f3f4f5; | |
| font-size: | |
| 16px; | |
| font-weight: | |
| 950; | |
| letter-spacing: | |
| .015em; | |
| } | |
| /* ========================================================= | |
| CENTERED LAYOUT | |
| ========================================================= */ | |
| .bracket.centered { | |
| display: | |
| grid; | |
| grid-template-columns: | |
| 1fr | |
| auto | |
| 1fr; | |
| gap: | |
| 95px; | |
| align-items: | |
| center; | |
| } | |
| .center-side { | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| gap: | |
| 60px; | |
| } | |
| .center-side.left { | |
| align-items: | |
| flex-end; | |
| } | |
| .center-side.right { | |
| align-items: | |
| flex-start; | |
| } | |
| .center-final { | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| justify-content: | |
| center; | |
| } | |
| /* ========================================================= | |
| COMPACT LAYOUT | |
| ========================================================= */ | |
| .bracket.compact { | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| gap: | |
| 28px; | |
| } | |
| .compact-round { | |
| display: | |
| grid; | |
| grid-template-columns: | |
| 135px | |
| 1fr; | |
| gap: | |
| 18px; | |
| align-items: | |
| stretch; | |
| } | |
| .compact-round > .round-title { | |
| height: | |
| auto; | |
| margin: | |
| 0; | |
| align-items: | |
| flex-start; | |
| padding-top: | |
| 8px; | |
| } | |
| .compact-round .round-matches { | |
| display: | |
| grid; | |
| grid-template-columns: | |
| repeat( | |
| auto-fit, | |
| minmax( | |
| var(--card-width), | |
| 1fr | |
| ) | |
| ); | |
| gap: | |
| 12px; | |
| } | |
| /* ========================================================= | |
| SPECTATOR MODE | |
| ========================================================= */ | |
| body.spectator-mode .team { | |
| cursor: | |
| default; | |
| } | |
| body.spectator-mode .team:hover { | |
| background: | |
| inherit; | |
| } | |
| body.spectator-mode .match:hover { | |
| transform: | |
| none; | |
| } | |
| /* ========================================================= | |
| RESPONSIVE | |
| ========================================================= */ | |
| @media (max-width: 900px) { | |
| .app { | |
| padding: | |
| 12px; | |
| } | |
| .header { | |
| align-items: | |
| flex-start; | |
| flex-direction: | |
| column; | |
| } | |
| .bracket { | |
| padding: | |
| 40px; | |
| } | |
| :root { | |
| --card-width: | |
| 215px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <!-- ===================================================== | |
| HEADER | |
| ====================================================== --> | |
| <header class="header"> | |
| <div class="brand"> | |
| <div class="brand-eyebrow"> | |
| Counter-Strike 2 · Playoffs | |
| </div> | |
| <h1 id="tournamentTitle"> | |
| IEM CS2 Championship | |
| </h1> | |
| <p id="tournamentSubtitle"> | |
| Playoffs · Single Elimination | |
| </p> | |
| </div> | |
| <div class="controls"> | |
| <span class="control-label"> | |
| Layout | |
| </span> | |
| <button | |
| class="layout-button active" | |
| data-layout="classic" | |
| > | |
| Classic | |
| </button> | |
| <button | |
| class="layout-button" | |
| data-layout="centered" | |
| > | |
| Centered | |
| </button> | |
| <button | |
| class="layout-button" | |
| data-layout="compact" | |
| > | |
| Compact | |
| </button> | |
| <div | |
| id="modeIndicator" | |
| class="mode-indicator" | |
| > | |
| <span class="mode-dot"></span> | |
| <span id="modeText"> | |
| Spectator | |
| </span> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- ===================================================== | |
| BRACKET | |
| ====================================================== --> | |
| <main class="bracket-shell"> | |
| <div | |
| id="bracket" | |
| class="bracket classic" | |
| ></div> | |
| </main> | |
| </div> | |
| <script> | |
| /* ========================================================= | |
| ========================================================= | |
| CONFIGURATION | |
| ========================================================= | |
| ========================================================= */ | |
| /* | |
| Available layouts: | |
| classic | |
| centered | |
| compact | |
| */ | |
| let LAYOUT_STYLE = | |
| "classic"; | |
| const CONFIG = { | |
| title: | |
| "IEM CS2 Championship", | |
| subtitle: | |
| "Playoffs · Single Elimination", | |
| /* | |
| false = spectator/read-only | |
| true = clickable | |
| */ | |
| interactiveMode: | |
| false, | |
| /* | |
| Default series. | |
| */ | |
| bestOf: | |
| "BO3", | |
| /* | |
| Tournament accent. | |
| */ | |
| accent: | |
| "#d6a84f", | |
| /* | |
| Display options. | |
| */ | |
| showSeeds: | |
| true, | |
| showScores: | |
| true, | |
| /* | |
| Dimensions. | |
| */ | |
| cardWidth: | |
| 250, | |
| roundGap: | |
| 100, | |
| matchGap: | |
| 28 | |
| }; | |
| /* ========================================================= | |
| TEAM DATABASE | |
| ========================================================= */ | |
| /* | |
| THIS IS THE SINGLE SOURCE OF TRUTH. | |
| Define each team ONCE. | |
| Every match below references | |
| these teams using their ID. | |
| Example: | |
| "navi" | |
| automatically resolves to: | |
| TEAMS.navi | |
| */ | |
| const TEAMS = { | |
| navi: { | |
| id: | |
| "navi", | |
| name: | |
| "Natus Vincere", | |
| seed: | |
| 3, | |
| logo: | |
| "", | |
| color: | |
| "#e4d449" | |
| }, | |
| spirit: { | |
| id: | |
| "spirit", | |
| name: | |
| "Spirit", | |
| seed: | |
| 1, | |
| logo: | |
| "", | |
| color: | |
| "#d83b3b" | |
| }, | |
| vitality: { | |
| id: | |
| "vitality", | |
| name: | |
| "Vitality", | |
| seed: | |
| 2, | |
| logo: | |
| "", | |
| color: | |
| "#e4bd42" | |
| }, | |
| g2: { | |
| id: | |
| "g2", | |
| name: | |
| "G2 Esports", | |
| seed: | |
| 4, | |
| logo: | |
| "", | |
| color: | |
| "#ffffff" | |
| }, | |
| faze: { | |
| id: | |
| "faze", | |
| name: | |
| "FaZe", | |
| seed: | |
| 5, | |
| logo: | |
| "", | |
| color: | |
| "#d32f2f" | |
| }, | |
| mouz: { | |
| id: | |
| "mouz", | |
| name: | |
| "MOUZ", | |
| seed: | |
| 6, | |
| logo: | |
| "", | |
| color: | |
| "#d9a43b" | |
| }, | |
| astralis: { | |
| id: | |
| "astralis", | |
| name: | |
| "Astralis", | |
| seed: | |
| 7, | |
| logo: | |
| "", | |
| color: | |
| "#d71920" | |
| }, | |
| liquid: { | |
| id: | |
| "liquid", | |
| name: | |
| "Liquid", | |
| seed: | |
| 8, | |
| logo: | |
| "", | |
| color: | |
| "#5d8ed8" | |
| } | |
| }; | |
| /* ========================================================= | |
| TOURNAMENT DATA | |
| ========================================================= */ | |
| /* | |
| Notice how small this is now. | |
| Teams are only referenced by ID. | |
| The same team can appear in: | |
| QF | |
| SF | |
| Final | |
| without repeating its information. | |
| */ | |
| const TOURNAMENT = { | |
| champion: | |
| "navi", | |
| rounds: [ | |
| /* ================================================= | |
| QUARTERFINALS | |
| ================================================= */ | |
| { | |
| name: | |
| "Quarterfinals", | |
| matches: [ | |
| { | |
| id: | |
| "qf1", | |
| bestOf: | |
| "BO3", | |
| teams: [ | |
| "navi", | |
| "faze" | |
| ], | |
| scores: [ | |
| 2, | |
| 1 | |
| ], | |
| winner: | |
| "navi" | |
| }, | |
| { | |
| id: | |
| "qf2", | |
| bestOf: | |
| "BO3", | |
| teams: [ | |
| "spirit", | |
| "g2" | |
| ], | |
| scores: [ | |
| 2, | |
| 0 | |
| ], | |
| winner: | |
| "spirit" | |
| }, | |
| { | |
| id: | |
| "qf3", | |
| bestOf: | |
| "BO3", | |
| teams: [ | |
| "vitality", | |
| "astralis" | |
| ], | |
| scores: [ | |
| 2, | |
| 1 | |
| ], | |
| winner: | |
| "vitality" | |
| }, | |
| { | |
| id: | |
| "qf4", | |
| bestOf: | |
| "BO3", | |
| teams: [ | |
| "mouz", | |
| "liquid" | |
| ], | |
| scores: [ | |
| 2, | |
| 1 | |
| ], | |
| winner: | |
| "mouz" | |
| } | |
| ] | |
| }, | |
| /* ================================================= | |
| SEMIFINALS | |
| ================================================= */ | |
| { | |
| name: | |
| "Semifinals", | |
| matches: [ | |
| { | |
| id: | |
| "sf1", | |
| bestOf: | |
| "BO3", | |
| teams: [ | |
| "navi", | |
| "spirit" | |
| ], | |
| scores: [ | |
| 2, | |
| 1 | |
| ], | |
| winner: | |
| "navi" | |
| }, | |
| { | |
| id: | |
| "sf2", | |
| bestOf: | |
| "BO3", | |
| teams: [ | |
| "vitality", | |
| "mouz" | |
| ], | |
| scores: [ | |
| 0, | |
| 2 | |
| ], | |
| winner: | |
| "mouz" | |
| } | |
| ] | |
| }, | |
| /* ================================================= | |
| GRAND FINAL | |
| ================================================= */ | |
| { | |
| name: | |
| "Grand Final", | |
| matches: [ | |
| { | |
| id: | |
| "final", | |
| bestOf: | |
| "BO5", | |
| teams: [ | |
| "navi", | |
| "mouz" | |
| ], | |
| scores: [ | |
| 3, | |
| 1 | |
| ], | |
| winner: | |
| "navi" | |
| } | |
| ] | |
| } | |
| ] | |
| }; | |
| /* ========================================================= | |
| TOURNAMENT STATE | |
| ========================================================= */ | |
| let tournament = { | |
| rounds: [], | |
| champion: | |
| null | |
| }; | |
| /* ========================================================= | |
| TEAM RESOLVER | |
| ========================================================= */ | |
| /* | |
| Converts: | |
| "navi" | |
| into: | |
| TEAMS.navi | |
| This prevents duplicated team data. | |
| */ | |
| function getTeam( | |
| id | |
| ) { | |
| const team = | |
| TEAMS[id]; | |
| if (!team) { | |
| console.warn( | |
| `Unknown team ID: ${id}` | |
| ); | |
| return { | |
| id: | |
| id, | |
| name: | |
| "Unknown Team", | |
| seed: | |
| null, | |
| logo: | |
| "", | |
| color: | |
| "#444" | |
| }; | |
| } | |
| /* | |
| Clone the object. | |
| This prevents match-specific | |
| state from modifying the | |
| original TEAMS database. | |
| */ | |
| return { | |
| ...team | |
| }; | |
| } | |
| /* ========================================================= | |
| RESOLVE MATCH | |
| ========================================================= */ | |
| function resolveMatch( | |
| match | |
| ) { | |
| const teams = | |
| match.teams.map( | |
| id => | |
| getTeam(id) | |
| ); | |
| teams.forEach( | |
| (team, index) => { | |
| team.score = | |
| match.scores?.[index] | |
| ?? | |
| 0; | |
| if ( | |
| match.winner && | |
| team.id === | |
| match.winner | |
| ) { | |
| team.status = | |
| "winner"; | |
| } | |
| else if ( | |
| match.winner | |
| ) { | |
| team.status = | |
| "eliminated"; | |
| } | |
| else { | |
| team.status = | |
| "active"; | |
| } | |
| } | |
| ); | |
| return { | |
| id: | |
| match.id, | |
| bestOf: | |
| match.bestOf || | |
| CONFIG.bestOf, | |
| teams, | |
| scores: | |
| match.scores || | |
| [], | |
| winner: | |
| match.winner || | |
| null | |
| }; | |
| } | |
| /* ========================================================= | |
| LOAD TOURNAMENT | |
| ========================================================= */ | |
| function loadTournament() { | |
| tournament.rounds = | |
| TOURNAMENT.rounds.map( | |
| round => { | |
| return round.matches.map( | |
| match => { | |
| return resolveMatch( | |
| match | |
| ); | |
| } | |
| ); | |
| } | |
| ); | |
| tournament.champion = | |
| getTeam( | |
| TOURNAMENT.champion | |
| ); | |
| render(); | |
| } | |
| /* ========================================================= | |
| AUTOMATIC BRACKET GENERATOR | |
| ========================================================= */ | |
| /* | |
| Used when interactiveMode is enabled | |
| and you want to generate a bracket | |
| directly from a list of contestants. | |
| */ | |
| function nextPowerOfTwo( | |
| number | |
| ) { | |
| let result = | |
| 1; | |
| while ( | |
| result < number | |
| ) { | |
| result *= 2; | |
| } | |
| return result; | |
| } | |
| function buildAutomaticBracket( | |
| contestants | |
| ) { | |
| const size = | |
| nextPowerOfTwo( | |
| contestants.length | |
| ); | |
| const players = | |
| contestants.map( | |
| id => | |
| getTeam(id) | |
| ); | |
| while ( | |
| players.length < | |
| size | |
| ) { | |
| players.push( | |
| null | |
| ); | |
| } | |
| tournament.rounds = | |
| []; | |
| const firstRound = | |
| []; | |
| for ( | |
| let i = 0; | |
| i < size; | |
| i += 2 | |
| ) { | |
| firstRound.push({ | |
| id: | |
| `r1-m${i / 2}`, | |
| bestOf: | |
| CONFIG.bestOf, | |
| teams: [ | |
| players[i], | |
| players[i + 1] | |
| ], | |
| winner: | |
| null | |
| }); | |
| } | |
| tournament.rounds.push( | |
| firstRound | |
| ); | |
| let matchCount = | |
| firstRound.length; | |
| let roundIndex = | |
| 1; | |
| while ( | |
| matchCount > 1 | |
| ) { | |
| matchCount /= | |
| 2; | |
| const round = | |
| []; | |
| for ( | |
| let i = 0; | |
| i < matchCount; | |
| i++ | |
| ) { | |
| round.push({ | |
| id: | |
| `r${roundIndex + 1}-m${i}`, | |
| bestOf: | |
| CONFIG.bestOf, | |
| teams: [ | |
| null, | |
| null | |
| ], | |
| winner: | |
| null | |
| }); | |
| } | |
| tournament.rounds.push( | |
| round | |
| ); | |
| roundIndex++; | |
| } | |
| propagateWinners(); | |
| render(); | |
| } | |
| /* ========================================================= | |
| PROPAGATE WINNERS | |
| ========================================================= */ | |
| function propagateWinners() { | |
| for ( | |
| let roundIndex = 0; | |
| roundIndex < | |
| tournament.rounds.length - 1; | |
| roundIndex++ | |
| ) { | |
| const current = | |
| tournament.rounds[ | |
| roundIndex | |
| ]; | |
| const next = | |
| tournament.rounds[ | |
| roundIndex + 1 | |
| ]; | |
| current.forEach( | |
| (match, index) => { | |
| if ( | |
| !match.winner | |
| ) { | |
| return; | |
| } | |
| const nextMatch = | |
| next[ | |
| Math.floor( | |
| index / 2 | |
| ) | |
| ]; | |
| const slot = | |
| index % 2; | |
| nextMatch.teams[slot] = | |
| { | |
| ...match.winner | |
| }; | |
| } | |
| ); | |
| } | |
| } | |
| /* ========================================================= | |
| ROUND NAME | |
| ========================================================= */ | |
| function getRoundName( | |
| index | |
| ) { | |
| if ( | |
| index === | |
| tournament.rounds.length - 1 | |
| ) { | |
| return "Grand Final"; | |
| } | |
| if ( | |
| index === | |
| tournament.rounds.length - 2 | |
| ) { | |
| return "Semifinals"; | |
| } | |
| if ( | |
| index === | |
| tournament.rounds.length - 3 | |
| ) { | |
| return "Quarterfinals"; | |
| } | |
| return `Round ${index + 1}`; | |
| } | |
| /* ========================================================= | |
| TEAM HTML | |
| ========================================================= */ | |
| function teamHTML( | |
| team, | |
| matchId | |
| ) { | |
| if (!team) { | |
| return ` | |
| <div | |
| class="team tbd readonly" | |
| > | |
| <div class="team-logo"> | |
| — | |
| </div> | |
| <div class="team-name"> | |
| TBD | |
| </div> | |
| <div class="score"></div> | |
| </div> | |
| `; | |
| } | |
| const status = | |
| team.status || | |
| "active"; | |
| const initials = | |
| team.name | |
| .substring(0, 2) | |
| .toUpperCase(); | |
| const logo = | |
| team.logo | |
| ? | |
| ` | |
| <img | |
| src="${team.logo}" | |
| alt="${team.name}" | |
| > | |
| ` | |
| : | |
| initials; | |
| const seed = | |
| CONFIG.showSeeds && | |
| team.seed | |
| ? | |
| ` | |
| <span class="team-seed"> | |
| #${team.seed} | |
| </span> | |
| ` | |
| : | |
| ""; | |
| const score = | |
| CONFIG.showScores | |
| ? | |
| ` | |
| <div class="score"> | |
| ${team.score ?? ""} | |
| </div> | |
| ` | |
| : | |
| ` | |
| <div class="score"></div> | |
| `; | |
| return ` | |
| <div | |
| class=" | |
| team | |
| ${status} | |
| ${!CONFIG.interactiveMode | |
| ? "readonly" | |
| : "" | |
| } | |
| " | |
| data-team-id="${team.id}" | |
| data-match-id="${matchId}" | |
| > | |
| <div | |
| class="team-logo" | |
| style=" | |
| border-top: | |
| 2px solid | |
| ${team.color || "#444"}; | |
| " | |
| > | |
| ${logo} | |
| </div> | |
| <div class="team-name"> | |
| ${seed} | |
| ${team.name} | |
| </div> | |
| ${score} | |
| </div> | |
| `; | |
| } | |
| /* ========================================================= | |
| MATCH HTML | |
| ========================================================= */ | |
| function matchHTML( | |
| match, | |
| isFinal = false | |
| ) { | |
| if (!match) { | |
| return ""; | |
| } | |
| const series = | |
| match.bestOf || | |
| CONFIG.bestOf; | |
| return ` | |
| <div | |
| class=" | |
| match | |
| ${isFinal | |
| ? "final-match" | |
| : "" | |
| } | |
| ${CONFIG.interactiveMode | |
| ? "interactive" | |
| : "" | |
| } | |
| " | |
| > | |
| <div class="match-header"> | |
| <span class="series"> | |
| ${series} | |
| </span> | |
| <span class="match-id"> | |
| ${match.id} | |
| </span> | |
| </div> | |
| ${teamHTML( | |
| match.teams?.[0], | |
| match.id | |
| )} | |
| ${teamHTML( | |
| match.teams?.[1], | |
| match.id | |
| )} | |
| ${ | |
| isFinal && | |
| tournament.champion | |
| ? | |
| ` | |
| <div class="champion"> | |
| 🏆 Champion | |
| <span | |
| class="champion-name" | |
| > | |
| ${ | |
| tournament | |
| .champion | |
| .name | |
| } | |
| </span> | |
| </div> | |
| ` | |
| : | |
| "" | |
| } | |
| </div> | |
| `; | |
| } | |
| /* ========================================================= | |
| CLASSIC | |
| ========================================================= */ | |
| function renderClassic() { | |
| const container = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| container.className = | |
| "bracket classic"; | |
| tournament.rounds | |
| .forEach( | |
| (round, index) => { | |
| const roundElement = | |
| document.createElement( | |
| "section" | |
| ); | |
| roundElement.className = | |
| "round"; | |
| roundElement.innerHTML = ` | |
| <div class="round-title"> | |
| ${getRoundName( | |
| index | |
| )} | |
| </div> | |
| `; | |
| const matches = | |
| document.createElement( | |
| "div" | |
| ); | |
| matches.className = | |
| "round-matches"; | |
| round.forEach( | |
| match => { | |
| matches.innerHTML += | |
| matchHTML( | |
| match, | |
| index === | |
| tournament.rounds.length - 1 | |
| ); | |
| } | |
| ); | |
| roundElement.appendChild( | |
| matches | |
| ); | |
| container.appendChild( | |
| roundElement | |
| ); | |
| } | |
| ); | |
| } | |
| /* ========================================================= | |
| CENTERED | |
| ========================================================= */ | |
| function renderCentered() { | |
| const container = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| container.className = | |
| "bracket centered"; | |
| const total = | |
| tournament.rounds.length; | |
| const left = | |
| document.createElement( | |
| "div" | |
| ); | |
| left.className = | |
| "center-side left"; | |
| const right = | |
| document.createElement( | |
| "div" | |
| ); | |
| right.className = | |
| "center-side right"; | |
| tournament.rounds | |
| .slice(0, -1) | |
| .forEach( | |
| (round, index) => { | |
| const wrapper = | |
| document.createElement( | |
| "div" | |
| ); | |
| wrapper.innerHTML = ` | |
| <div class="round-title"> | |
| ${getRoundName( | |
| index | |
| )} | |
| </div> | |
| `; | |
| round.forEach( | |
| match => { | |
| wrapper.innerHTML += | |
| matchHTML( | |
| match | |
| ); | |
| } | |
| ); | |
| if ( | |
| index % 2 === 0 | |
| ) { | |
| left.appendChild( | |
| wrapper | |
| ); | |
| } | |
| else { | |
| right.appendChild( | |
| wrapper | |
| ); | |
| } | |
| } | |
| ); | |
| const final = | |
| tournament.rounds[ | |
| total - 1 | |
| ]?.[0]; | |
| const finalColumn = | |
| document.createElement( | |
| "div" | |
| ); | |
| finalColumn.className = | |
| "center-final"; | |
| finalColumn.innerHTML = ` | |
| <div class="round-title"> | |
| GRAND FINAL | |
| </div> | |
| ${matchHTML( | |
| final, | |
| true | |
| )} | |
| `; | |
| container.appendChild( | |
| left | |
| ); | |
| container.appendChild( | |
| finalColumn | |
| ); | |
| container.appendChild( | |
| right | |
| ); | |
| } | |
| /* ========================================================= | |
| COMPACT | |
| ========================================================= */ | |
| function renderCompact() { | |
| const container = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| container.className = | |
| "bracket compact"; | |
| tournament.rounds | |
| .forEach( | |
| (round, index) => { | |
| const wrapper = | |
| document.createElement( | |
| "section" | |
| ); | |
| wrapper.className = | |
| "compact-round"; | |
| wrapper.innerHTML = ` | |
| <div class="round-title"> | |
| ${getRoundName( | |
| index | |
| )} | |
| </div> | |
| `; | |
| const matches = | |
| document.createElement( | |
| "div" | |
| ); | |
| matches.className = | |
| "round-matches"; | |
| round.forEach( | |
| match => { | |
| matches.innerHTML += | |
| matchHTML( | |
| match, | |
| index === | |
| tournament.rounds.length - 1 | |
| ); | |
| } | |
| ); | |
| wrapper.appendChild( | |
| matches | |
| ); | |
| container.appendChild( | |
| wrapper | |
| ); | |
| } | |
| ); | |
| } | |
| /* ========================================================= | |
| SELECT WINNER | |
| ========================================================= */ | |
| function selectWinner( | |
| matchId, | |
| teamId | |
| ) { | |
| /* | |
| Spectator mode: | |
| absolutely no manual changes. | |
| */ | |
| if ( | |
| !CONFIG.interactiveMode | |
| ) { | |
| return; | |
| } | |
| let match = | |
| null; | |
| for ( | |
| const round | |
| of tournament.rounds | |
| ) { | |
| const found = | |
| round.find( | |
| m => | |
| m.id === | |
| matchId | |
| ); | |
| if (found) { | |
| match = | |
| found; | |
| break; | |
| } | |
| } | |
| if (!match) | |
| return; | |
| const winner = | |
| match.teams.find( | |
| team => | |
| team && | |
| team.id === | |
| teamId | |
| ); | |
| if (!winner) | |
| return; | |
| const loser = | |
| match.teams.find( | |
| team => | |
| team && | |
| team.id !== | |
| teamId | |
| ); | |
| winner.status = | |
| "winner"; | |
| if (loser) { | |
| loser.status = | |
| "eliminated"; | |
| } | |
| match.winner = | |
| winner; | |
| /* | |
| Final winner. | |
| */ | |
| if ( | |
| match === | |
| tournament.rounds | |
| .at(-1) | |
| ?.at(0) | |
| ) { | |
| tournament.champion = | |
| winner; | |
| } | |
| propagateWinners(); | |
| render(); | |
| } | |
| /* ========================================================= | |
| ATTACH INTERACTIONS | |
| ========================================================= */ | |
| function attachInteractions() { | |
| document | |
| .querySelectorAll( | |
| ".team" | |
| ) | |
| .forEach( | |
| element => { | |
| if ( | |
| !CONFIG.interactiveMode | |
| ) { | |
| element.classList | |
| .add("readonly"); | |
| return; | |
| } | |
| element.addEventListener( | |
| "click", | |
| () => { | |
| selectWinner( | |
| element.dataset.matchId, | |
| element.dataset.teamId | |
| ); | |
| } | |
| ); | |
| } | |
| ); | |
| } | |
| /* ========================================================= | |
| RENDER | |
| ========================================================= */ | |
| function render() { | |
| const container = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| container.innerHTML = | |
| ""; | |
| document.documentElement | |
| .style | |
| .setProperty( | |
| "--card-width", | |
| `${CONFIG.cardWidth}px` | |
| ); | |
| document.documentElement | |
| .style | |
| .setProperty( | |
| "--round-gap", | |
| `${CONFIG.roundGap}px` | |
| ); | |
| document.documentElement | |
| .style | |
| .setProperty( | |
| "--match-gap", | |
| `${CONFIG.matchGap}px` | |
| ); | |
| document.documentElement | |
| .style | |
| .setProperty( | |
| "--accent", | |
| CONFIG.accent | |
| ); | |
| document.body.classList.toggle( | |
| "spectator-mode", | |
| !CONFIG.interactiveMode | |
| ); | |
| switch ( | |
| LAYOUT_STYLE | |
| ) { | |
| case "classic": | |
| renderClassic(); | |
| break; | |
| case "centered": | |
| renderCentered(); | |
| break; | |
| case "compact": | |
| renderCompact(); | |
| break; | |
| } | |
| attachInteractions(); | |
| updateModeIndicator(); | |
| } | |
| /* ========================================================= | |
| MODE INDICATOR | |
| ========================================================= */ | |
| function updateModeIndicator() { | |
| const indicator = | |
| document.getElementById( | |
| "modeIndicator" | |
| ); | |
| const text = | |
| document.getElementById( | |
| "modeText" | |
| ); | |
| if ( | |
| CONFIG.interactiveMode | |
| ) { | |
| indicator.classList | |
| .add("live"); | |
| text.textContent = | |
| "Interactive"; | |
| } | |
| else { | |
| indicator.classList | |
| .remove("live"); | |
| text.textContent = | |
| "Spectator"; | |
| } | |
| } | |
| /* ========================================================= | |
| CHANGE LAYOUT | |
| ========================================================= */ | |
| function setLayout( | |
| layout | |
| ) { | |
| const allowed = [ | |
| "classic", | |
| "centered", | |
| "compact" | |
| ]; | |
| if ( | |
| !allowed.includes( | |
| layout | |
| ) | |
| ) { | |
| throw new Error( | |
| `Invalid layout: ${layout}` | |
| ); | |
| } | |
| LAYOUT_STYLE = | |
| layout; | |
| document | |
| .querySelectorAll( | |
| "[data-layout]" | |
| ) | |
| .forEach( | |
| button => { | |
| button.classList.toggle( | |
| "active", | |
| button.dataset.layout === | |
| layout | |
| ); | |
| } | |
| ); | |
| render(); | |
| } | |
| /* ========================================================= | |
| CHANGE INTERACTIVE MODE | |
| ========================================================= */ | |
| function setInteractiveMode( | |
| enabled | |
| ) { | |
| CONFIG.interactiveMode = | |
| Boolean(enabled); | |
| render(); | |
| } | |
| /* ========================================================= | |
| UPDATE MATCH | |
| ========================================================= */ | |
| /* | |
| Example: | |
| updateMatch( | |
| "final", | |
| { | |
| scores: [3, 2], | |
| winner: "navi" | |
| } | |
| ); | |
| This updates the state without | |
| having to rewrite the tournament. | |
| */ | |
| function updateMatch( | |
| matchId, | |
| data | |
| ) { | |
| for ( | |
| const round | |
| of tournament.rounds | |
| ) { | |
| const match = | |
| round.find( | |
| m => | |
| m.id === | |
| matchId | |
| ); | |
| if (!match) | |
| continue; | |
| Object.assign( | |
| match, | |
| data | |
| ); | |
| /* | |
| Update scores. | |
| */ | |
| if ( | |
| data.scores | |
| ) { | |
| match.scores = | |
| data.scores; | |
| match.teams | |
| .forEach( | |
| (team, index) => { | |
| if (team) { | |
| team.score = | |
| data.scores[ | |
| index | |
| ] ?? | |
| 0; | |
| } | |
| } | |
| ); | |
| } | |
| /* | |
| Update winner. | |
| */ | |
| if ( | |
| data.winner | |
| ) { | |
| match.winner = | |
| data.winner; | |
| match.teams | |
| .forEach( | |
| team => { | |
| if (!team) | |
| return; | |
| team.status = | |
| team.id === | |
| data.winner | |
| ? | |
| "winner" | |
| : | |
| "eliminated"; | |
| } | |
| ); | |
| if ( | |
| match.id === | |
| "final" | |
| ) { | |
| tournament.champion = | |
| match.teams.find( | |
| team => | |
| team && | |
| team.id === | |
| data.winner | |
| ); | |
| } | |
| } | |
| render(); | |
| return; | |
| } | |
| } | |
| /* ========================================================= | |
| REPLACE TOURNAMENT DATA | |
| ========================================================= */ | |
| function setTournament( | |
| data | |
| ) { | |
| /* | |
| Replace the global tournament | |
| definition at runtime. | |
| */ | |
| tournament.rounds = | |
| data.rounds.map( | |
| round => { | |
| return round.matches.map( | |
| match => | |
| resolveMatch( | |
| match | |
| ) | |
| ); | |
| } | |
| ); | |
| tournament.champion = | |
| getTeam( | |
| data.champion | |
| ); | |
| render(); | |
| } | |
| /* ========================================================= | |
| LAYOUT BUTTONS | |
| ========================================================= */ | |
| document | |
| .querySelectorAll( | |
| "[data-layout]" | |
| ) | |
| .forEach( | |
| button => { | |
| button.addEventListener( | |
| "click", | |
| () => { | |
| setLayout( | |
| button.dataset.layout | |
| ); | |
| } | |
| ); | |
| } | |
| ); | |
| /* ========================================================= | |
| HEADER | |
| ========================================================= */ | |
| document.getElementById( | |
| "tournamentTitle" | |
| ).textContent = | |
| CONFIG.title; | |
| document.getElementById( | |
| "tournamentSubtitle" | |
| ).textContent = | |
| CONFIG.subtitle; | |
| /* ========================================================= | |
| INITIALIZE | |
| ========================================================= */ | |
| if ( | |
| CONFIG.interactiveMode | |
| ) { | |
| /* | |
| Interactive mode can generate | |
| a bracket from team IDs. | |
| */ | |
| buildAutomaticBracket([ | |
| "navi", | |
| "spirit", | |
| "vitality", | |
| "g2", | |
| "faze", | |
| "mouz", | |
| "astralis", | |
| "liquid" | |
| ]); | |
| } | |
| else { | |
| /* | |
| Spectator mode loads the | |
| predefined tournament state. | |
| */ | |
| loadTournament(); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment