Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save EncodeTheCode/f4ab506a0df5aee5e462b9d94cc1d215 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/f4ab506a0df5aee5e462b9d94cc1d215 to your computer and use it in GitHub Desktop.
<!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