Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save EncodeTheCode/77c6ac91a379f3973a044d1590742a0c 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>
/* =========================================================
ROOT / 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;
--card-height: 94px;
--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;
}
/* =========================================================
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 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 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 {
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
========================================================= */
.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 {
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 ENTRY
========================================================= */
.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;
}
/* =========================================================
TEAM 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>
/* =========================================================
=========================================================
CS2 TOURNAMENT BRACKET CONFIGURATION
=========================================================
========================================================= */
/*
---------------------------------------------------------
LAYOUT
---------------------------------------------------------
"classic"
Traditional left → right bracket.
"centered"
Bracket converges around the Grand Final.
"compact"
Dense tournament-board layout.
*/
let LAYOUT_STYLE = "classic";
/*
---------------------------------------------------------
INTERACTION MODE
---------------------------------------------------------
true:
Teams can be clicked.
Clicking a team advances it.
false:
Spectator mode.
Nothing can be clicked.
The bracket displays only the
state supplied by your data.
This is the main switch you requested.
*/
const CONFIG = {
title:
"IEM CS2 Championship",
subtitle:
"Playoffs · Single Elimination",
/*
CHANGE THIS TO true IF YOU WANT
CLICKABLE MATCHES.
*/
interactiveMode:
false,
/*
Default series length.
*/
bestOf:
"BO3",
/*
Accent color used for
Grand Final / tournament branding.
*/
accent:
"#d6a84f",
/*
Display options.
*/
showSeeds:
true,
showScores:
true,
/*
Layout dimensions.
*/
cardWidth:
250,
roundGap:
100,
matchGap:
28
};
/* =========================================================
TEAM DATA
========================================================= */
/*
You can replace this entire array.
The bracket will automatically resize
itself according to the number of teams.
Supported properties:
id
name
seed
logo
color
status
score
status can be:
"active"
"winner"
"eliminated"
*/
const contestants = [
{
id: "spirit",
name: "Spirit",
seed: 1,
logo: "",
color: "#d83b3b"
},
{
id: "vitality",
name: "Vitality",
seed: 2,
logo: "",
color: "#e4bd42"
},
{
id: "navi",
name: "Natus Vincere",
seed: 3,
logo: "",
color: "#e4d449"
},
{
id: "g2",
name: "G2 Esports",
seed: 4,
logo: "",
color: "#ffffff"
},
{
id: "faze",
name: "FaZe",
seed: 5,
logo: "",
color: "#d32f2f"
},
{
id: "mouz",
name: "MOUZ",
seed: 6,
logo: "",
color: "#d9a43b"
},
{
id: "astralis",
name: "Astralis",
seed: 7,
logo: "",
color: "#d71920"
},
{
id: "liquid",
name: "Liquid",
seed: 8,
logo: "",
color: "#5d8ed8"
}
];
/* =========================================================
PREDEFINED TOURNAMENT STATE
========================================================= */
/*
THIS IS THE IMPORTANT PART FOR
SPECTATOR MODE.
You don't have to click anything.
You can define the tournament state
directly here.
Each match can have:
teams
winner
scores
statuses
bestOf
The renderer simply displays it.
*/
const PREDEFINED_MATCHES = [
/*
-----------------------------------------------
QUARTERFINAL 1
-----------------------------------------------
*/
{
id: "qf1",
round: "Quarterfinals",
bestOf: "BO3",
teams: [
{
id: "navi",
name: "Natus Vincere",
seed: 3,
score: 2,
status: "winner",
logo: "",
color: "#e4d449"
},
{
id: "faze",
name: "FaZe",
seed: 5,
score: 1,
status: "eliminated",
logo: "",
color: "#d32f2f"
}
],
winner: "navi"
},
/*
-----------------------------------------------
QUARTERFINAL 2
-----------------------------------------------
*/
{
id: "qf2",
round: "Quarterfinals",
bestOf: "BO3",
teams: [
{
id: "spirit",
name: "Spirit",
seed: 1,
score: 2,
status: "winner",
logo: "",
color: "#d83b3b"
},
{
id: "g2",
name: "G2 Esports",
seed: 4,
score: 0,
status: "eliminated",
logo: "",
color: "#ffffff"
}
],
winner: "spirit"
},
/*
-----------------------------------------------
QUARTERFINAL 3
-----------------------------------------------
*/
{
id: "qf3",
round: "Quarterfinals",
bestOf: "BO3",
teams: [
{
id: "vitality",
name: "Vitality",
seed: 2,
score: 2,
status: "winner",
logo: "",
color: "#e4bd42"
},
{
id: "astralis",
name: "Astralis",
seed: 7,
score: 1,
status: "eliminated",
logo: "",
color: "#d71920"
}
],
winner: "vitality"
},
/*
-----------------------------------------------
QUARTERFINAL 4
-----------------------------------------------
*/
{
id: "qf4",
round: "Quarterfinals",
bestOf: "BO3",
teams: [
{
id: "mouz",
name: "MOUZ",
seed: 6,
score: 2,
status: "winner",
logo: "",
color: "#d9a43b"
},
{
id: "liquid",
name: "Liquid",
seed: 8,
score: 1,
status: "eliminated",
logo: "",
color: "#5d8ed8"
}
],
winner: "mouz"
},
/*
-----------------------------------------------
SEMIFINAL 1
-----------------------------------------------
*/
{
id: "sf1",
round: "Semifinals",
bestOf: "BO3",
teams: [
{
id: "navi",
name: "Natus Vincere",
seed: 3,
score: 2,
status: "winner",
logo: "",
color: "#e4d449"
},
{
id: "spirit",
name: "Spirit",
seed: 1,
score: 1,
status: "eliminated",
logo: "",
color: "#d83b3b"
}
],
winner: "navi"
},
/*
-----------------------------------------------
SEMIFINAL 2
-----------------------------------------------
*/
{
id: "sf2",
round: "Semifinals",
bestOf: "BO3",
teams: [
{
id: "vitality",
name: "Vitality",
seed: 2,
score: 0,
status: "eliminated",
logo: "",
color: "#e4bd42"
},
{
id: "mouz",
name: "MOUZ",
seed: 6,
score: 2,
status: "winner",
logo: "",
color: "#d9a43b"
}
],
winner: "mouz"
},
/*
-----------------------------------------------
GRAND FINAL
-----------------------------------------------
*/
{
id: "final",
round: "Grand Final",
bestOf: "BO5",
teams: [
{
id: "navi",
name: "Natus Vincere",
seed: 3,
score: 3,
status: "winner",
logo: "",
color: "#e4d449"
},
{
id: "mouz",
name: "MOUZ",
seed: 6,
score: 1,
status: "eliminated",
logo: "",
color: "#d9a43b"
}
],
winner: "navi"
}
];
/*
The final champion is explicitly defined.
This means spectator mode doesn't need
to calculate anything from clicks.
*/
const PREDEFINED_CHAMPION =
"navi";
/* =========================================================
STATE
========================================================= */
let tournament = {
rounds: [],
champion: null
};
/* =========================================================
HELPERS
========================================================= */
function nextPowerOfTwo(number) {
let result = 1;
while (result < number) {
result *= 2;
}
return result;
}
function roundName(index, total) {
const distance =
total - index;
if (distance === 1)
return "Grand Final";
if (distance === 2)
return "Semifinals";
if (distance === 3)
return "Quarterfinals";
return `Round ${index + 1}`;
}
function cloneTeam(team) {
if (!team)
return null;
return {
...team
};
}
/* =========================================================
BUILD AUTOMATIC BRACKET
========================================================= */
function buildAutomaticBracket() {
const size =
nextPowerOfTwo(
contestants.length
);
const players =
contestants.map(
team => ({
...team,
status:
team.status ||
"active",
score:
team.score ??
0
})
);
while (
players.length < size
) {
players.push(null);
}
tournament.rounds = [];
/*
First round.
*/
const firstRound = [];
for (
let i = 0;
i < size;
i += 2
) {
firstRound.push({
id:
`r1-m${i / 2}`,
round:
0,
teams: [
cloneTeam(
players[i]
),
cloneTeam(
players[i + 1]
)
],
winner:
null,
bestOf:
CONFIG.bestOf
});
}
tournament.rounds.push(
firstRound
);
/*
Generate remaining rounds.
*/
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}`,
round:
roundIndex,
teams: [
null,
null
],
winner:
null,
bestOf:
CONFIG.bestOf
});
}
tournament.rounds.push(
round
);
roundIndex++;
}
processAutomaticResults();
}
/* =========================================================
PROCESS AUTOMATIC RESULTS
========================================================= */
function processAutomaticResults() {
/*
Apply BYEs.
*/
tournament.rounds[0]
.forEach(match => {
const a =
match.teams[0];
const b =
match.teams[1];
if (a && !b) {
a.status =
"winner";
match.winner =
a;
}
if (!a && b) {
b.status =
"winner";
match.winner =
b;
}
});
propagateAutomaticWinners();
}
/* =========================================================
PROPAGATE WINNERS
========================================================= */
function propagateAutomaticWinners() {
for (
let r = 0;
r <
tournament.rounds.length - 1;
r++
) {
const current =
tournament.rounds[r];
const next =
tournament.rounds[r + 1];
current.forEach(
(match, index) => {
if (!match.winner)
return;
const nextMatch =
next[
Math.floor(
index / 2
)
];
const slot =
index % 2;
if (
!nextMatch.teams[slot]
) {
nextMatch.teams[slot] =
cloneTeam(
match.winner
);
}
}
);
}
}
/* =========================================================
BUILD PREDEFINED BRACKET
========================================================= */
function buildPredefinedBracket() {
/*
Group supplied matches by round.
*/
const groups = {};
PREDEFINED_MATCHES
.forEach(match => {
if (!groups[match.round]) {
groups[match.round] = [];
}
groups[match.round]
.push({
...match,
teams:
match.teams.map(
cloneTeam
)
});
});
const order = [
"Round 1",
"Round 2",
"Quarterfinals",
"Semifinals",
"Grand Final"
];
const discoveredRounds =
Object.keys(groups);
/*
Use supplied round order.
*/
let ordered =
order.filter(
round =>
groups[round]
);
/*
Include custom round names
that aren't in the defaults.
*/
discoveredRounds
.forEach(round => {
if (
!ordered.includes(round)
) {
ordered.push(round);
}
});
tournament.rounds =
ordered.map(
(roundNameValue, index) => {
return groups[
roundNameValue
].map(
match => ({
...match,
round:
index
})
);
}
);
/*
Champion.
*/
tournament.champion =
findChampion(
PREDEFINED_CHAMPION
);
}
/* =========================================================
FIND CHAMPION
========================================================= */
function findChampion(
championId
) {
for (
const round
of tournament.rounds
) {
for (
const match
of round
) {
const team =
match.teams.find(
t =>
t &&
t.id ===
championId
);
if (team) {
return team;
}
}
}
return null;
}
/* =========================================================
TEAM HTML
========================================================= */
function teamHTML(
team,
matchId
) {
if (!team) {
return `
<div class="team tbd">
<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
) {
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 LAYOUT
========================================================= */
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">
${roundName(
index,
tournament.rounds.length
)}
</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 LAYOUT
========================================================= */
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">
${roundName(
index,
total
)}
</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 LAYOUT
========================================================= */
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">
${roundName(
index,
tournament.rounds.length
)}
</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
);
}
);
}
/* =========================================================
INTERACTIVE WINNER SELECTION
========================================================= */
function selectWinner(
matchId,
teamId
) {
/*
Spectator mode completely blocks
manual state 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 selected =
match.teams.find(
team =>
team &&
team.id ===
teamId
);
if (!selected)
return;
const loser =
match.teams.find(
team =>
team &&
team.id !==
teamId
);
selected.status =
"winner";
if (loser) {
loser.status =
"eliminated";
}
match.winner =
selected;
/*
Final.
*/
const finalRound =
tournament.rounds[
tournament.rounds.length - 1
];
if (
finalRound.includes(
match
)
) {
tournament.champion =
selected;
}
propagateAutomaticWinners();
render();
}
/* =========================================================
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`
);
/*
Spectator body state.
*/
document.body.classList.toggle(
"spectator-mode",
!CONFIG.interactiveMode
);
/*
Choose layout.
*/
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();
}
/* =========================================================
SET INTERACTIVE MODE
========================================================= */
function setInteractiveMode(
enabled
) {
CONFIG.interactiveMode =
Boolean(enabled);
render();
}
/* =========================================================
REPLACE CONTESTANTS
========================================================= */
function setContestants(
newContestants
) {
contestants =
newContestants.map(
(team, index) => ({
id:
team.id ??
`team-${index}`,
name:
team.name ??
"Unknown",
seed:
team.seed ??
index + 1,
logo:
team.logo ??
"",
color:
team.color ??
"#444",
status:
team.status ??
"active",
score:
team.score ??
0
})
);
tournament.champion =
null;
buildAutomaticBracket();
render();
}
/* =========================================================
LOAD PREDEFINED STATE
========================================================= */
function loadPredefinedTournament() {
buildPredefinedBracket();
render();
}
/* =========================================================
UPDATE A SINGLE MATCH
========================================================= */
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
);
if (
data.winner
) {
const winner =
match.teams.find(
team =>
team &&
team.id ===
data.winner
);
if (winner) {
winner.status =
"winner";
match.winner =
winner;
}
match.teams
.forEach(
team => {
if (
team &&
team.id !==
data.winner
) {
team.status =
"eliminated";
}
}
);
}
if (
match.id ===
"final" &&
data.winner
) {
tournament.champion =
match.teams.find(
team =>
team &&
team.id ===
data.winner
);
}
render();
return;
}
}
/* =========================================================
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;
document.documentElement
.style
.setProperty(
"--accent",
CONFIG.accent
);
/* =========================================================
INITIALIZATION
========================================================= */
/*
Because interactiveMode is false,
the predefined tournament is loaded
as a read-only spectator bracket.
*/
if (
CONFIG.interactiveMode
) {
buildAutomaticBracket();
} else {
loadPredefinedTournament();
}
render();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment