Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save EncodeTheCode/7f89eda8d1f40a682c3936e7b1499a4d 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;
--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