Created
August 14, 2026 16:00
-
-
Save EncodeTheCode/7f89eda8d1f40a682c3936e7b1499a4d 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; | |
| --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: | |
| 115px; | |
| --match-gap: | |
| 32px; | |
| --shadow: | |
| 0 15px 40px rgba(0,0,0,.42); | |
| --connector: | |
| #48525c; | |
| --connector-bright: | |
| #68737d; | |
| } | |
| /* ========================================================= | |
| 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; | |
| } | |
| /* ========================================================= | |
| APP | |
| ========================================================= */ | |
| .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 | |
| ========================================================= */ | |
| .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 SHELL | |
| ========================================================= */ | |
| .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 ROOT | |
| ========================================================= */ | |
| .bracket { | |
| position: | |
| relative; | |
| min-width: | |
| max-content; | |
| min-height: | |
| 720px; | |
| padding: | |
| 65px 70px; | |
| isolation: | |
| isolate; | |
| } | |
| /* ========================================================= | |
| CONNECTOR SVG | |
| This sits BEHIND the actual matches. | |
| ========================================================= */ | |
| .connector-layer { | |
| position: | |
| absolute; | |
| inset: | |
| 0; | |
| width: | |
| 100%; | |
| height: | |
| 100%; | |
| z-index: | |
| 0; | |
| pointer-events: | |
| none; | |
| overflow: | |
| visible; | |
| } | |
| /* Main connector */ | |
| .connector { | |
| fill: | |
| none; | |
| stroke: | |
| var(--connector); | |
| stroke-width: | |
| 1.5; | |
| vector-effect: | |
| non-scaling-stroke; | |
| stroke-linecap: | |
| square; | |
| stroke-linejoin: | |
| round; | |
| opacity: | |
| .85; | |
| } | |
| /* Highlight line */ | |
| .connector-highlight { | |
| fill: | |
| none; | |
| stroke: | |
| var(--connector-bright); | |
| stroke-width: | |
| .75; | |
| vector-effect: | |
| non-scaling-stroke; | |
| opacity: | |
| .28; | |
| } | |
| /* Active/winner route */ | |
| .connector.winner-route { | |
| stroke: | |
| rgba(173,216,181,.75); | |
| filter: | |
| drop-shadow( | |
| 0 0 3px | |
| rgba(173,216,181,.18) | |
| ); | |
| } | |
| /* Final route */ | |
| .connector.final-route { | |
| stroke: | |
| var(--accent); | |
| opacity: | |
| .9; | |
| filter: | |
| drop-shadow( | |
| 0 0 4px | |
| rgba(214,168,79,.18) | |
| ); | |
| } | |
| /* ========================================================= | |
| ROUNDS | |
| ========================================================= */ | |
| .round { | |
| position: | |
| relative; | |
| z-index: | |
| 2; | |
| 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 | |
| ========================================================= */ | |
| .bracket.classic { | |
| display: | |
| flex; | |
| align-items: | |
| stretch; | |
| gap: | |
| var(--round-gap); | |
| } | |
| /* ========================================================= | |
| MATCH | |
| ========================================================= */ | |
| .match { | |
| position: | |
| relative; | |
| width: | |
| var(--card-width); | |
| 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 { | |
| position: | |
| relative; | |
| z-index: | |
| 2; | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| gap: | |
| 60px; | |
| } | |
| .center-side.left { | |
| align-items: | |
| flex-end; | |
| } | |
| .center-side.right { | |
| align-items: | |
| flex-start; | |
| } | |
| .center-final { | |
| position: | |
| relative; | |
| z-index: | |
| 2; | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| justify-content: | |
| center; | |
| } | |
| /* ========================================================= | |
| COMPACT | |
| ========================================================= */ | |
| .bracket.compact { | |
| display: | |
| flex; | |
| flex-direction: | |
| column; | |
| gap: | |
| 28px; | |
| } | |
| .compact-round { | |
| position: | |
| relative; | |
| z-index: | |
| 2; | |
| 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 | |
| ========================================================= */ | |
| 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 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> | |
| <main class="bracket-shell"> | |
| <div | |
| id="bracket" | |
| class="bracket classic" | |
| > | |
| <!-- SVG is injected here by JavaScript --> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| /* ========================================================= | |
| CONFIG | |
| ========================================================= */ | |
| let LAYOUT_STYLE = | |
| "classic"; | |
| const CONFIG = { | |
| title: | |
| "IEM CS2 Championship", | |
| subtitle: | |
| "Playoffs · Single Elimination", | |
| /* | |
| false: | |
| spectator/read-only | |
| true: | |
| clicking a team makes it win | |
| */ | |
| interactiveMode: | |
| false, | |
| bestOf: | |
| "BO3", | |
| accent: | |
| "#d6a84f", | |
| showSeeds: | |
| true, | |
| showScores: | |
| true, | |
| cardWidth: | |
| 250, | |
| roundGap: | |
| 115, | |
| matchGap: | |
| 32 | |
| }; | |
| /* ========================================================= | |
| TEAMS | |
| ========================================================= */ | |
| 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 | |
| ========================================================= */ | |
| const TOURNAMENT = { | |
| champion: | |
| "navi", | |
| rounds: [ | |
| { | |
| 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" | |
| } | |
| ] | |
| }, | |
| { | |
| 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" | |
| } | |
| ] | |
| }, | |
| { | |
| name: | |
| "Grand Final", | |
| matches: [ | |
| { | |
| id: | |
| "final", | |
| bestOf: | |
| "BO5", | |
| teams: [ | |
| "navi", | |
| "mouz" | |
| ], | |
| scores: [ | |
| 3, | |
| 1 | |
| ], | |
| winner: | |
| "navi" | |
| } | |
| ] | |
| } | |
| ] | |
| }; | |
| /* ========================================================= | |
| STATE | |
| ========================================================= */ | |
| let tournament = { | |
| rounds: | |
| [], | |
| champion: | |
| null | |
| }; | |
| /* ========================================================= | |
| TEAM RESOLVER | |
| ========================================================= */ | |
| function getTeam( | |
| id | |
| ) { | |
| if (!id) { | |
| return null; | |
| } | |
| const team = | |
| TEAMS[id]; | |
| if (!team) { | |
| console.warn( | |
| `Unknown team ID: ${id}` | |
| ); | |
| return { | |
| id: | |
| id, | |
| name: | |
| "Unknown Team", | |
| seed: | |
| null, | |
| logo: | |
| "", | |
| color: | |
| "#444" | |
| }; | |
| } | |
| return { | |
| ...team | |
| }; | |
| } | |
| /* ========================================================= | |
| RESOLVE MATCH | |
| ========================================================= */ | |
| function resolveMatch( | |
| match | |
| ) { | |
| const teams = | |
| match.teams.map( | |
| id => | |
| getTeam(id) | |
| ); | |
| teams.forEach( | |
| (team, index) => { | |
| if (!team) | |
| return; | |
| 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 | |
| ========================================================= */ | |
| function loadTournament() { | |
| tournament.rounds = | |
| TOURNAMENT.rounds.map( | |
| round => { | |
| return round.matches.map( | |
| match => | |
| resolveMatch( | |
| match | |
| ) | |
| ); | |
| } | |
| ); | |
| tournament.champion = | |
| getTeam( | |
| TOURNAMENT.champion | |
| ); | |
| render(); | |
| } | |
| /* ========================================================= | |
| 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 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 | |
| ${team.status || "active"} | |
| ${!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 ""; | |
| return ` | |
| <div | |
| class=" | |
| match | |
| ${isFinal | |
| ? "final-match" | |
| : "" | |
| } | |
| ${CONFIG.interactiveMode | |
| ? "interactive" | |
| : "" | |
| } | |
| " | |
| data-match-id="${match.id}" | |
| > | |
| <div class="match-header"> | |
| <span class="series"> | |
| ${match.bestOf} | |
| </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> | |
| `; | |
| } | |
| /* ========================================================= | |
| SVG CONNECTOR SYSTEM | |
| ========================================================= */ | |
| /* | |
| THIS IS THE IMPORTANT PART. | |
| Instead of guessing where lines should go, | |
| we measure the actual rendered match boxes. | |
| Therefore the connectors automatically | |
| adapt to the layout. | |
| */ | |
| function createConnectorLayer() { | |
| const bracket = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| const old = | |
| bracket.querySelector( | |
| ".connector-layer" | |
| ); | |
| if (old) { | |
| old.remove(); | |
| } | |
| const svg = | |
| document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "svg" | |
| ); | |
| svg.classList.add( | |
| "connector-layer" | |
| ); | |
| svg.setAttribute( | |
| "aria-hidden", | |
| "true" | |
| ); | |
| /* | |
| Put SVG first so it stays behind | |
| every bracket element. | |
| */ | |
| bracket.prepend( | |
| svg | |
| ); | |
| return svg; | |
| } | |
| /* ========================================================= | |
| GET MATCH RECT | |
| ========================================================= */ | |
| function getMatchRect( | |
| matchElement, | |
| bracketRect | |
| ) { | |
| const rect = | |
| matchElement.getBoundingClientRect(); | |
| return { | |
| left: | |
| rect.left - | |
| bracketRect.left, | |
| right: | |
| rect.right - | |
| bracketRect.left, | |
| top: | |
| rect.top - | |
| bracketRect.top, | |
| bottom: | |
| rect.bottom - | |
| bracketRect.top, | |
| centerX: | |
| rect.left - | |
| bracketRect.left + | |
| rect.width / 2, | |
| centerY: | |
| rect.top - | |
| bracketRect.top + | |
| rect.height / 2 | |
| }; | |
| } | |
| /* ========================================================= | |
| DRAW PATH | |
| ========================================================= */ | |
| function drawPath( | |
| svg, | |
| points, | |
| className = "connector" | |
| ) { | |
| if ( | |
| points.length < | |
| 2 | |
| ) { | |
| return; | |
| } | |
| const path = | |
| document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "path" | |
| ); | |
| let d = | |
| `M ${points[0].x} ${points[0].y}`; | |
| for ( | |
| let i = 1; | |
| i < points.length; | |
| i++ | |
| ) { | |
| d += | |
| ` L ${points[i].x} ${points[i].y}`; | |
| } | |
| path.setAttribute( | |
| "d", | |
| d | |
| ); | |
| path.setAttribute( | |
| "class", | |
| className | |
| ); | |
| svg.appendChild( | |
| path | |
| ); | |
| } | |
| /* ========================================================= | |
| CONNECT TWO MATCHES | |
| ========================================================= */ | |
| function connectMatches( | |
| svg, | |
| from, | |
| to, | |
| bracketRect, | |
| options = {} | |
| ) { | |
| const a = | |
| getMatchRect( | |
| from, | |
| bracketRect | |
| ); | |
| const b = | |
| getMatchRect( | |
| to, | |
| bracketRect | |
| ); | |
| /* | |
| Normal bracket flow: | |
| [MATCH] ----\ | |
| >---- [MATCH] | |
| [MATCH] ----/ | |
| */ | |
| const startX = | |
| a.right; | |
| const endX = | |
| b.left; | |
| /* | |
| Gap midpoint. | |
| This is what creates the classic | |
| CS/HLTV-style elbow shape. | |
| */ | |
| const middleX = | |
| startX + | |
| (endX - startX) / 2; | |
| /* | |
| Use the center of each match. | |
| This keeps the connector perfectly | |
| aligned even if cards change height. | |
| */ | |
| const startY = | |
| a.centerY; | |
| const endY = | |
| b.centerY; | |
| const path = [ | |
| { | |
| x: | |
| startX, | |
| y: | |
| startY | |
| }, | |
| { | |
| x: | |
| middleX, | |
| y: | |
| startY | |
| }, | |
| { | |
| x: | |
| middleX, | |
| y: | |
| endY | |
| }, | |
| { | |
| x: | |
| endX, | |
| y: | |
| endY | |
| } | |
| ]; | |
| let className = | |
| "connector"; | |
| if ( | |
| options.winner | |
| ) { | |
| className += | |
| " winner-route"; | |
| } | |
| if ( | |
| options.final | |
| ) { | |
| className += | |
| " final-route"; | |
| } | |
| drawPath( | |
| svg, | |
| path, | |
| className | |
| ); | |
| /* | |
| Add a very subtle second highlight | |
| line for the premium tournament look. | |
| */ | |
| if ( | |
| !options.final | |
| ) { | |
| const highlight = path.map( | |
| point => ({ | |
| x: | |
| point.x, | |
| y: | |
| point.y - .7 | |
| }) | |
| ); | |
| drawPath( | |
| svg, | |
| highlight, | |
| "connector-highlight" | |
| ); | |
| } | |
| } | |
| /* ========================================================= | |
| CLASSIC CONNECTORS | |
| ========================================================= */ | |
| function drawClassicConnectors( | |
| svg | |
| ) { | |
| const bracket = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| const bracketRect = | |
| bracket.getBoundingClientRect(); | |
| /* | |
| Every round connects to the | |
| immediately following round. | |
| */ | |
| for ( | |
| let roundIndex = 0; | |
| roundIndex < | |
| tournament.rounds.length - 1; | |
| roundIndex++ | |
| ) { | |
| const currentRound = | |
| tournament.rounds[ | |
| roundIndex | |
| ]; | |
| const nextRound = | |
| tournament.rounds[ | |
| roundIndex + 1 | |
| ]; | |
| currentRound.forEach( | |
| (match, matchIndex) => { | |
| /* | |
| Standard single-elimination | |
| bracket pairing: | |
| match 0 ─┐ | |
| ├─ next match 0 | |
| match 1 ─┘ | |
| match 2 ─┐ | |
| ├─ next match 1 | |
| match 3 ─┘ | |
| */ | |
| const nextIndex = | |
| Math.floor( | |
| matchIndex / 2 | |
| ); | |
| const nextMatch = | |
| nextRound[ | |
| nextIndex | |
| ]; | |
| if (!nextMatch) | |
| return; | |
| const from = | |
| bracket.querySelector( | |
| `[data-match-id="${match.id}"]` | |
| ); | |
| const to = | |
| bracket.querySelector( | |
| `[data-match-id="${nextMatch.id}"]` | |
| ); | |
| if ( | |
| !from || | |
| !to | |
| ) { | |
| return; | |
| } | |
| /* | |
| Determine whether this path | |
| belongs to a winner route. | |
| This gives already-decided routes | |
| a subtle green highlight. | |
| */ | |
| const winnerRoute = | |
| match.winner && | |
| nextMatch.teams?.some( | |
| team => | |
| team && | |
| team.id === | |
| match.winner.id | |
| ); | |
| connectMatches( | |
| svg, | |
| from, | |
| to, | |
| bracketRect, | |
| { | |
| winner: | |
| Boolean( | |
| winnerRoute | |
| ), | |
| final: | |
| roundIndex === | |
| tournament.rounds.length - 2 | |
| } | |
| ); | |
| } | |
| ); | |
| } | |
| } | |
| /* ========================================================= | |
| CENTERED CONNECTORS | |
| ========================================================= */ | |
| function drawCenteredConnectors( | |
| svg | |
| ) { | |
| /* | |
| The centered layout has a different | |
| visual flow. | |
| Left side: | |
| Quarterfinals -> Semifinal | |
| Right side: | |
| Semifinal <- Quarterfinals | |
| Both connect toward the center final. | |
| */ | |
| const bracket = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| const bracketRect = | |
| bracket.getBoundingClientRect(); | |
| const matches = | |
| [ | |
| ...bracket.querySelectorAll( | |
| ".match" | |
| ) | |
| ]; | |
| /* | |
| We use actual screen positions instead | |
| of relying on DOM order. | |
| */ | |
| const final = | |
| matches.find( | |
| element => | |
| element.classList.contains( | |
| "final-match" | |
| ) | |
| ); | |
| if (!final) | |
| return; | |
| const finalRect = | |
| getMatchRect( | |
| final, | |
| bracketRect | |
| ); | |
| /* | |
| Connect all non-final matches | |
| toward the final using the closest | |
| geometric relationship. | |
| This keeps the lines stable if the | |
| centered layout changes size. | |
| */ | |
| const nonFinal = | |
| matches.filter( | |
| element => | |
| element !== final | |
| ); | |
| const left = | |
| nonFinal.filter( | |
| element => { | |
| const rect = | |
| getMatchRect( | |
| element, | |
| bracketRect | |
| ); | |
| return ( | |
| rect.centerX < | |
| finalRect.centerX | |
| ); | |
| } | |
| ); | |
| const right = | |
| nonFinal.filter( | |
| element => { | |
| const rect = | |
| getMatchRect( | |
| element, | |
| bracketRect | |
| ); | |
| return ( | |
| rect.centerX > | |
| finalRect.centerX | |
| ); | |
| } | |
| ); | |
| /* | |
| Connect neighboring pairs on each side. | |
| */ | |
| connectSideToFinal( | |
| svg, | |
| left, | |
| final, | |
| bracketRect, | |
| false | |
| ); | |
| connectSideToFinal( | |
| svg, | |
| right, | |
| final, | |
| bracketRect, | |
| true | |
| ); | |
| } | |
| /* ========================================================= | |
| CENTER SIDE -> FINAL | |
| ========================================================= */ | |
| function connectSideToFinal( | |
| svg, | |
| elements, | |
| final, | |
| bracketRect, | |
| reverse | |
| ) { | |
| if ( | |
| !elements.length | |
| ) | |
| return; | |
| /* | |
| Sort by vertical position. | |
| */ | |
| elements.sort( | |
| (a, b) => { | |
| const ar = | |
| getMatchRect( | |
| a, | |
| bracketRect | |
| ); | |
| const br = | |
| getMatchRect( | |
| b, | |
| bracketRect | |
| ); | |
| return ( | |
| ar.centerY - | |
| br.centerY | |
| ); | |
| } | |
| ); | |
| /* | |
| Pair neighboring matches. | |
| This gives the familiar: | |
| ──┐ | |
| ├── | |
| ──┘ | |
| visual before reaching the final. | |
| */ | |
| for ( | |
| let i = 0; | |
| i < elements.length; | |
| i += 2 | |
| ) { | |
| const first = | |
| elements[i]; | |
| const second = | |
| elements[i + 1]; | |
| if ( | |
| !second | |
| ) { | |
| connectMatches( | |
| svg, | |
| first, | |
| final, | |
| bracketRect, | |
| { | |
| final: | |
| true | |
| } | |
| ); | |
| continue; | |
| } | |
| const a = | |
| getMatchRect( | |
| first, | |
| bracketRect | |
| ); | |
| const b = | |
| getMatchRect( | |
| second, | |
| bracketRect | |
| ); | |
| const f = | |
| getMatchRect( | |
| final, | |
| bracketRect | |
| ); | |
| const outerX = | |
| reverse | |
| ? | |
| Math.max( | |
| a.left, | |
| b.left | |
| ) - 45 | |
| : | |
| Math.min( | |
| a.right, | |
| b.right | |
| ) + 45; | |
| const centerY = | |
| ( | |
| a.centerY + | |
| b.centerY | |
| ) / 2; | |
| /* | |
| First branch. | |
| */ | |
| drawPath( | |
| svg, | |
| [ | |
| { | |
| x: | |
| reverse | |
| ? a.left | |
| : a.right, | |
| y: | |
| a.centerY | |
| }, | |
| { | |
| x: | |
| outerX, | |
| y: | |
| a.centerY | |
| }, | |
| { | |
| x: | |
| outerX, | |
| y: | |
| centerY | |
| } | |
| ], | |
| "connector" | |
| ); | |
| /* | |
| Second branch. | |
| */ | |
| drawPath( | |
| svg, | |
| [ | |
| { | |
| x: | |
| reverse | |
| ? b.left | |
| : b.right, | |
| y: | |
| b.centerY | |
| }, | |
| { | |
| x: | |
| outerX, | |
| y: | |
| b.centerY | |
| }, | |
| { | |
| x: | |
| outerX, | |
| y: | |
| centerY | |
| } | |
| ], | |
| "connector" | |
| ); | |
| /* | |
| Combined route to final. | |
| */ | |
| const finalX = | |
| reverse | |
| ? f.right | |
| : f.left; | |
| drawPath( | |
| svg, | |
| [ | |
| { | |
| x: | |
| outerX, | |
| y: | |
| centerY | |
| }, | |
| { | |
| x: | |
| finalX, | |
| y: | |
| centerY | |
| } | |
| ], | |
| "connector final-route" | |
| ); | |
| } | |
| } | |
| /* ========================================================= | |
| COMPACT CONNECTORS | |
| ========================================================= */ | |
| function drawCompactConnectors( | |
| svg | |
| ) { | |
| /* | |
| Compact mode is essentially a | |
| horizontal grid of match groups. | |
| We connect matches using their | |
| actual geometry. | |
| This prevents fixed CSS lines from | |
| becoming misaligned when cards wrap. | |
| */ | |
| const bracket = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| const bracketRect = | |
| bracket.getBoundingClientRect(); | |
| 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) => { | |
| const nextIndex = | |
| Math.floor( | |
| index / 2 | |
| ); | |
| const nextMatch = | |
| next[ | |
| nextIndex | |
| ]; | |
| if (!nextMatch) | |
| return; | |
| const from = | |
| bracket.querySelector( | |
| `[data-match-id="${match.id}"]` | |
| ); | |
| const to = | |
| bracket.querySelector( | |
| `[data-match-id="${nextMatch.id}"]` | |
| ); | |
| if ( | |
| !from || | |
| !to | |
| ) | |
| return; | |
| connectMatches( | |
| svg, | |
| from, | |
| to, | |
| bracketRect, | |
| { | |
| winner: | |
| Boolean( | |
| match.winner | |
| ), | |
| final: | |
| roundIndex === | |
| tournament.rounds.length - 2 | |
| } | |
| ); | |
| } | |
| ); | |
| } | |
| } | |
| /* ========================================================= | |
| DRAW ALL CONNECTORS | |
| ========================================================= */ | |
| function drawConnectors() { | |
| const bracket = | |
| document.getElementById( | |
| "bracket" | |
| ); | |
| if (!bracket) | |
| return; | |
| /* | |
| SVG must have the same actual dimensions | |
| as the bracket. | |
| */ | |
| const width = | |
| bracket.scrollWidth; | |
| const height = | |
| bracket.scrollHeight; | |
| const svg = | |
| createConnectorLayer(); | |
| svg.setAttribute( | |
| "viewBox", | |
| `0 0 ${width} ${height}` | |
| ); | |
| svg.setAttribute( | |
| "width", | |
| width | |
| ); | |
| svg.setAttribute( | |
| "height", | |
| height | |
| ); | |
| /* | |
| Allow layout to finish before | |
| measuring elements. | |
| */ | |
| requestAnimationFrame( | |
| () => { | |
| switch ( | |
| LAYOUT_STYLE | |
| ) { | |
| case "classic": | |
| drawClassicConnectors( | |
| svg | |
| ); | |
| break; | |
| case "centered": | |
| drawCenteredConnectors( | |
| svg | |
| ); | |
| break; | |
| case "compact": | |
| drawCompactConnectors( | |
| svg | |
| ); | |
| break; | |
| } | |
| } | |
| ); | |
| } | |
| /* ========================================================= | |
| CLASSIC RENDER | |
| ========================================================= */ | |
| 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 RENDER | |
| ========================================================= */ | |
| 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 RENDER | |
| ========================================================= */ | |
| 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 | |
| ); | |
| } | |
| ); | |
| } | |
| /* ========================================================= | |
| INTERACTIONS | |
| ========================================================= */ | |
| function selectWinner( | |
| matchId, | |
| teamId | |
| ) { | |
| 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; | |
| /* | |
| Winner moves to next round. | |
| */ | |
| propagateWinners(); | |
| /* | |
| If this is the final, | |
| crown the champion. | |
| */ | |
| const final = | |
| tournament.rounds | |
| .at(-1) | |
| ?.at(0); | |
| if ( | |
| match === final | |
| ) { | |
| tournament.champion = | |
| winner; | |
| } | |
| 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 | |
| ) | |
| ]; | |
| if (!nextMatch) | |
| return; | |
| const slot = | |
| index % 2; | |
| nextMatch.teams[ | |
| slot | |
| ] = { | |
| ...match.winner, | |
| score: | |
| 0, | |
| status: | |
| "active" | |
| }; | |
| } | |
| ); | |
| } | |
| } | |
| /* ========================================================= | |
| ATTACH INTERACTIONS | |
| ========================================================= */ | |
| function attachInteractions() { | |
| document | |
| .querySelectorAll( | |
| ".team" | |
| ) | |
| .forEach( | |
| element => { | |
| if ( | |
| !CONFIG.interactiveMode | |
| ) { | |
| return; | |
| } | |
| if ( | |
| !element.dataset.teamId | |
| ) { | |
| return; | |
| } | |
| element.addEventListener( | |
| "click", | |
| () => { | |
| selectWinner( | |
| element.dataset.matchId, | |
| element.dataset.teamId | |
| ); | |
| } | |
| ); | |
| } | |
| ); | |
| } | |
| /* ========================================================= | |
| MODE | |
| ========================================================= */ | |
| function updateModeIndicator() { | |
| const indicator = | |
| document.getElementById( | |
| "modeIndicator" | |
| ); | |
| const text = | |
| document.getElementById( | |
| "modeText" | |
| ); | |
| indicator.classList.toggle( | |
| "live", | |
| CONFIG.interactiveMode | |
| ); | |
| text.textContent = | |
| CONFIG.interactiveMode | |
| ? "Interactive" | |
| : "Spectator"; | |
| } | |
| /* ========================================================= | |
| MAIN 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(); | |
| /* | |
| IMPORTANT: | |
| Draw connectors AFTER | |
| the DOM has been rendered. | |
| */ | |
| drawConnectors(); | |
| } | |
| /* ========================================================= | |
| CHANGE LAYOUT | |
| ========================================================= */ | |
| function setLayout( | |
| layout | |
| ) { | |
| const allowed = [ | |
| "classic", | |
| "centered", | |
| "compact" | |
| ]; | |
| if ( | |
| !allowed.includes( | |
| layout | |
| ) | |
| ) { | |
| return; | |
| } | |
| 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 | |
| ========================================================= */ | |
| function updateMatch( | |
| matchId, | |
| data | |
| ) { | |
| for ( | |
| const round | |
| of tournament.rounds | |
| ) { | |
| const match = | |
| round.find( | |
| m => | |
| m.id === | |
| matchId | |
| ); | |
| if (!match) | |
| continue; | |
| if ( | |
| data.scores | |
| ) { | |
| match.scores = | |
| data.scores; | |
| match.teams.forEach( | |
| (team, index) => { | |
| if (!team) | |
| return; | |
| team.score = | |
| data.scores[ | |
| index | |
| ] ?? | |
| 0; | |
| } | |
| ); | |
| } | |
| if ( | |
| data.winner | |
| ) { | |
| match.winner = | |
| data.winner; | |
| match.teams.forEach( | |
| team => { | |
| if (!team) | |
| return; | |
| team.status = | |
| team.id === | |
| data.winner | |
| ? | |
| "winner" | |
| : | |
| "eliminated"; | |
| } | |
| ); | |
| } | |
| propagateWinners(); | |
| const final = | |
| tournament.rounds | |
| .at(-1) | |
| ?.at(0); | |
| if ( | |
| match === final && | |
| match.winner | |
| ) { | |
| tournament.champion = | |
| match.teams.find( | |
| team => | |
| team && | |
| team.id === | |
| match.winner | |
| ); | |
| } | |
| render(); | |
| return; | |
| } | |
| } | |
| /* ========================================================= | |
| RESIZE OBSERVER | |
| ========================================================= */ | |
| /* | |
| This is what makes the connector system | |
| genuinely dynamic. | |
| If a card changes size because: | |
| - team name changes | |
| - logo loads | |
| - font changes | |
| - browser resizes | |
| - layout changes | |
| - CSS changes | |
| the lines are redrawn. | |
| */ | |
| const resizeObserver = | |
| new ResizeObserver( | |
| () => { | |
| /* | |
| Avoid doing this before the | |
| initial DOM exists. | |
| */ | |
| if ( | |
| document.querySelector( | |
| ".match" | |
| ) | |
| ) { | |
| drawConnectors(); | |
| } | |
| } | |
| ); | |
| resizeObserver.observe( | |
| document.body | |
| ); | |
| /* ========================================================= | |
| WINDOW RESIZE | |
| ========================================================= */ | |
| window.addEventListener( | |
| "resize", | |
| () => { | |
| drawConnectors(); | |
| } | |
| ); | |
| /* ========================================================= | |
| LOGO LOAD | |
| ========================================================= */ | |
| document.addEventListener( | |
| "load", | |
| event => { | |
| if ( | |
| event.target.tagName === | |
| "IMG" | |
| ) { | |
| drawConnectors(); | |
| } | |
| }, | |
| true | |
| ); | |
| /* ========================================================= | |
| 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 | |
| ========================================================= */ | |
| loadTournament(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment