@amirhossein-shk/tournament-bracket-js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
1
+ (function(global, factory) {
2
+ typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define([], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, global.tournamentBracket = factory());
3
+ })(this, function() {
4
+ //#region src/tournament-bracket.js
5
+ function tournamentBracket(userConfig = {}) {
6
+ const DEFAULT_PLAYER = {
7
+ name: "-",
8
+ avatarUrl: "",
9
+ score: "-"
10
+ };
11
+ const DEFAULT_CONFIG = {
12
+ targetId: "tournament-bracket",
13
+ rounds: null,
14
+ distance: 42,
15
+ width: 196,
16
+ matchHeight: 94,
17
+ roundGap: 96,
18
+ avatarFallbackUrl: "data:image/svg+xml;utf8," + encodeURIComponent(`
19
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">
20
+ <rect width="100%" height="100%" fill="#e5e7eb"/>
21
+ <text x="50%" y="54%" text-anchor="middle" font-size="28" fill="#6b7280">?</text>
22
+ </svg>
23
+ `),
24
+ connectorColor: "white",
25
+ onMatchClick: null,
26
+ onMatchUpdate: null,
27
+ onMatchFinish: null
28
+ };
29
+ function normalizeConfig(userConfig) {
30
+ return {
31
+ ...DEFAULT_CONFIG,
32
+ ...userConfig,
33
+ rounds: userConfig.rounds ?? []
34
+ };
35
+ }
36
+ const config = normalizeConfig(userConfig);
37
+ let bracketState = null;
38
+ function validateRoundsShape(rounds) {
39
+ if (!Array.isArray(rounds) || rounds.length === 0) throw new Error("rounds must be a non-empty array.");
40
+ rounds.forEach((round, roundIndex) => {
41
+ if (!round || typeof round !== "object") throw new Error(`Round at index ${roundIndex} must be an object.`);
42
+ if (!Array.isArray(round.matches)) throw new Error(`rounds[${roundIndex}].matches must be an array.`);
43
+ if (round.matches.length === 0) throw new Error(`rounds[${roundIndex}].matches must not be empty.`);
44
+ round.matches.forEach((match, matchIndex) => {
45
+ if (!match || typeof match !== "object") throw new Error(`rounds[${roundIndex}].matches[${matchIndex}] must be an object.`);
46
+ if (!Array.isArray(match.players)) throw new Error(`rounds[${roundIndex}].matches[${matchIndex}].players must be an array.`);
47
+ if (match.players.length !== 2) throw new Error(`rounds[${roundIndex}].matches[${matchIndex}] must have exactly 2 players.`);
48
+ });
49
+ });
50
+ const firstRoundMatchesCount = rounds[0].matches.length;
51
+ if (!isPowerOfTwo(firstRoundMatchesCount)) throw new Error("First round matches count must be a power of two.");
52
+ for (let i = 1; i < rounds.length; i++) {
53
+ const expectedMatchesCount = rounds[i - 1].matches.length / 2;
54
+ const actualMatchesCount = rounds[i].matches.length;
55
+ if (actualMatchesCount !== expectedMatchesCount) throw new Error(`Invalid bracket shape: rounds[${i}] must have ${expectedMatchesCount} matches, but got ${actualMatchesCount}.`);
56
+ }
57
+ if (rounds[rounds.length - 1].matches.length !== 1) throw new Error("Last round must have exactly 1 match.");
58
+ }
59
+ function validateConfig() {
60
+ const container = document.getElementById(config.targetId);
61
+ if (!container) throw new Error(`Element with id "${config.targetId}" not found`);
62
+ validateRoundsShape(config.rounds);
63
+ return container;
64
+ }
65
+ function isPowerOfTwo(number) {
66
+ return number > 0 && (number & number - 1) === 0;
67
+ }
68
+ function createId(prefix) {
69
+ if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`;
70
+ return `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
71
+ }
72
+ function createEmptyPlayer() {
73
+ return { ...DEFAULT_PLAYER };
74
+ }
75
+ function normalizeMatch(match) {
76
+ const isFinished = typeof match.isFinished === "boolean" ? match.isFinished : match.status === "completed";
77
+ return {
78
+ matchId: match.matchId || createId("match"),
79
+ status: match.status || (isFinished ? "completed" : "pending"),
80
+ isFinished,
81
+ players: [{
82
+ ...DEFAULT_PLAYER,
83
+ ...match.players?.[0]
84
+ }, {
85
+ ...DEFAULT_PLAYER,
86
+ ...match.players?.[1]
87
+ }]
88
+ };
89
+ }
90
+ function mergeMatchData(currentMatch, nextMatchData) {
91
+ const nextPlayers = nextMatchData.players || [];
92
+ return normalizeMatch({
93
+ ...currentMatch,
94
+ ...nextMatchData,
95
+ players: [{
96
+ ...currentMatch.players?.[0],
97
+ ...nextPlayers[0]
98
+ }, {
99
+ ...currentMatch.players?.[1],
100
+ ...nextPlayers[1]
101
+ }]
102
+ });
103
+ }
104
+ function buildBracketModel(rounds) {
105
+ return { rounds: rounds.map((round, roundIndex) => {
106
+ const matches = round.matches.map((match, matchIndex) => {
107
+ const normalized = normalizeMatch(match);
108
+ normalized.roundIndex = roundIndex;
109
+ normalized.matchIndex = matchIndex;
110
+ return normalized;
111
+ });
112
+ return {
113
+ roundId: round.roundId || createId("round"),
114
+ name: round.name || `Round ${roundIndex + 1}`,
115
+ roundIndex,
116
+ matches
117
+ };
118
+ }) };
119
+ }
120
+ function isPendingScore(score) {
121
+ return score === "-" || score === "" || score === null || score === void 0;
122
+ }
123
+ function getMatchState(match) {
124
+ const [firstPlayer, secondPlayer] = match.players;
125
+ const firstScorePending = isPendingScore(firstPlayer.score);
126
+ const secondScorePending = isPendingScore(secondPlayer.score);
127
+ if (firstScorePending && secondScorePending) return {
128
+ status: "pending",
129
+ winnerIndexes: []
130
+ };
131
+ const firstScore = Number(firstPlayer.score);
132
+ const secondScore = Number(secondPlayer.score);
133
+ if (Number.isNaN(firstScore) || Number.isNaN(secondScore)) return {
134
+ status: "invalid-score",
135
+ winnerIndexes: []
136
+ };
137
+ if (firstScore === secondScore) return {
138
+ status: "draw",
139
+ winnerIndexes: []
140
+ };
141
+ if (!match.isFinished) return {
142
+ status: "in-progress",
143
+ winnerIndexes: []
144
+ };
145
+ return {
146
+ status: "completed",
147
+ winnerIndexes: [firstScore > secondScore ? 0 : 1]
148
+ };
149
+ }
150
+ function getPlayerIdentity(player = {}) {
151
+ return player.id || player.name || "";
152
+ }
153
+ function createAdvancedPlayer(winner, currentTargetPlayer = {}, previousMatchId) {
154
+ const winnerIdentity = getPlayerIdentity(winner);
155
+ const currentIdentity = getPlayerIdentity(currentTargetPlayer);
156
+ const isSamePlayer = winnerIdentity && currentIdentity && winnerIdentity === currentIdentity;
157
+ return {
158
+ name: winner.name ?? "",
159
+ avatarUrl: winner.avatarUrl ?? "",
160
+ seed: winner.seed,
161
+ id: winner.id,
162
+ score: isSamePlayer ? currentTargetPlayer.score ?? "-" : "-",
163
+ previousMatchId
164
+ };
165
+ }
166
+ function propagateWinner(roundIndex, matchIndex) {
167
+ const match = bracketState.rounds[roundIndex]?.matches?.[matchIndex];
168
+ if (!match) return;
169
+ const matchState = getMatchState(match);
170
+ const nextRound = bracketState.rounds[roundIndex + 1];
171
+ if (!nextRound) return;
172
+ const nextMatchIndex = Math.floor(matchIndex / 2);
173
+ const nextMatch = nextRound.matches[nextMatchIndex];
174
+ if (!nextMatch) return;
175
+ const playerIndex = matchIndex % 2;
176
+ const winner = matchState.status === "completed" ? match.players[matchState.winnerIndexes[0]] : null;
177
+ if (!winner) nextMatch.players[playerIndex] = createEmptyPlayer();
178
+ else nextMatch.players[playerIndex] = createAdvancedPlayer(winner, nextMatch.players[playerIndex], match.matchId);
179
+ }
180
+ function resolveBracket() {
181
+ for (let roundIndex = 0; roundIndex < bracketState.rounds.length - 1; roundIndex++) bracketState.rounds[roundIndex].matches.forEach((match, matchIndex) => {
182
+ propagateWinner(roundIndex, matchIndex);
183
+ });
184
+ }
185
+ function createBracketLayout(bracket) {
186
+ const roundWidth = config.width;
187
+ const roundGap = config.roundGap;
188
+ const matchHeight = config.matchHeight;
189
+ const matchGap = config.distance;
190
+ const rounds = bracket.rounds.map((round, roundIndex) => {
191
+ const x = roundIndex * (roundWidth + roundGap);
192
+ return {
193
+ ...round,
194
+ x,
195
+ matches: round.matches.map((match, matchIndex) => ({
196
+ ...match,
197
+ x,
198
+ y: matchIndex * (matchHeight + matchGap),
199
+ width: roundWidth,
200
+ height: matchHeight
201
+ }))
202
+ };
203
+ });
204
+ for (let roundIndex = 1; roundIndex < rounds.length; roundIndex++) {
205
+ const previousRound = rounds[roundIndex - 1];
206
+ const currentRound = rounds[roundIndex];
207
+ currentRound.matches = currentRound.matches.map((match, matchIndex) => {
208
+ const firstSourceMatch = previousRound.matches[matchIndex * 2];
209
+ const secondSourceMatch = previousRound.matches[matchIndex * 2 + 1];
210
+ const y = (getMatchCenterY(firstSourceMatch) + getMatchCenterY(secondSourceMatch)) / 2 - matchHeight / 2;
211
+ return {
212
+ ...match,
213
+ y
214
+ };
215
+ });
216
+ }
217
+ return {
218
+ rounds,
219
+ connectors: createConnectors(rounds),
220
+ width: calculateLayoutWidth(rounds),
221
+ height: calculateLayoutHeight(rounds)
222
+ };
223
+ }
224
+ function getMatchCenterY(match) {
225
+ return match.y + match.height / 2;
226
+ }
227
+ function createConnectors(rounds) {
228
+ const connectors = [];
229
+ for (let roundIndex = 0; roundIndex < rounds.length - 1; roundIndex++) {
230
+ const currentRound = rounds[roundIndex];
231
+ const nextRound = rounds[roundIndex + 1];
232
+ currentRound.matches.forEach((match, matchIndex) => {
233
+ const targetMatch = nextRound.matches[Math.floor(matchIndex / 2)];
234
+ if (!targetMatch) return;
235
+ connectors.push({
236
+ id: createId("connector"),
237
+ from: {
238
+ x: match.x + match.width,
239
+ y: getMatchCenterY(match)
240
+ },
241
+ to: {
242
+ x: targetMatch.x,
243
+ y: getMatchCenterY(targetMatch)
244
+ }
245
+ });
246
+ });
247
+ }
248
+ return connectors;
249
+ }
250
+ function calculateLayoutWidth(rounds) {
251
+ const lastMatch = rounds[rounds.length - 1].matches[0];
252
+ return lastMatch.x + lastMatch.width;
253
+ }
254
+ function calculateLayoutHeight(rounds) {
255
+ return Math.max(...rounds.flatMap((round) => round.matches.map((match) => match.y + match.height)));
256
+ }
257
+ function clearContainer(container) {
258
+ container.textContent = "";
259
+ }
260
+ function renderBracket(container, layout) {
261
+ clearContainer(container);
262
+ const root = document.createElement("div");
263
+ root.className = "tournament-bracket-container";
264
+ root.style.position = "relative";
265
+ root.style.width = `${layout.width}px`;
266
+ root.style.height = `${layout.height}px`;
267
+ const svg = createSvgLayer(layout);
268
+ root.appendChild(svg);
269
+ layout.rounds.forEach((round) => {
270
+ root.appendChild(createRoundElement(round));
271
+ });
272
+ container.appendChild(root);
273
+ }
274
+ function createSvgLayer(layout) {
275
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
276
+ svg.classList.add("tournament-bracket-connectors");
277
+ svg.setAttribute("width", String(layout.width));
278
+ svg.setAttribute("height", String(layout.height));
279
+ svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
280
+ svg.style.position = "absolute";
281
+ svg.style.inset = "0";
282
+ svg.style.overflow = "visible";
283
+ svg.style.pointerEvents = "none";
284
+ svg.style.zIndex = "0";
285
+ layout.connectors.forEach((connector) => {
286
+ svg.appendChild(createConnectorPath(connector));
287
+ });
288
+ return svg;
289
+ }
290
+ function createConnectorPath(connector) {
291
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
292
+ const middleX = connector.from.x + (connector.to.x - connector.from.x) / 2;
293
+ const d = [
294
+ `M ${connector.from.x} ${connector.from.y}`,
295
+ `H ${middleX}`,
296
+ `V ${connector.to.y}`,
297
+ `H ${connector.to.x}`
298
+ ].join(" ");
299
+ path.setAttribute("d", d);
300
+ path.setAttribute("fill", "none");
301
+ path.setAttribute("stroke", config.connectorColor);
302
+ path.setAttribute("stroke-width", "1");
303
+ path.setAttribute("stroke-linejoin", "round");
304
+ path.setAttribute("stroke-linecap", "round");
305
+ path.classList.add("tournament-bracket-connector");
306
+ return path;
307
+ }
308
+ function createRoundElement(round) {
309
+ const element = document.createElement("div");
310
+ element.className = "tournament-bracket-column";
311
+ element.dataset.roundId = round.roundId;
312
+ element.dataset.roundName = round.name;
313
+ element.dataset.roundIndex = String(round.roundIndex);
314
+ element.style.position = "absolute";
315
+ element.style.left = `${round.x}px`;
316
+ element.style.top = "0";
317
+ element.style.width = `${config.width}px`;
318
+ element.style.zIndex = "1";
319
+ round.matches.forEach((match) => {
320
+ element.appendChild(createMatchElement(match));
321
+ });
322
+ return element;
323
+ }
324
+ function createMatchElement(match) {
325
+ const element = document.createElement("div");
326
+ const matchState = getMatchState(match);
327
+ element.id = match.matchId;
328
+ element.className = "match-container";
329
+ element.dataset.matchStatus = matchState.status;
330
+ element.style.position = "absolute";
331
+ element.style.top = `${match.y}px`;
332
+ element.style.left = "0";
333
+ element.style.width = `${match.width}px`;
334
+ element.style.height = `${match.height}px`;
335
+ const vsElement = document.createElement("div");
336
+ vsElement.className = "player-vs";
337
+ vsElement.textContent = "VS";
338
+ element.appendChild(vsElement);
339
+ element.appendChild(createPlayerElement(match.players[0], "tournament-bracket-first-player", matchState.winnerIndexes.includes(0)));
340
+ element.appendChild(createPlayerElement(match.players[1], "tournament-bracket-second-player", matchState.winnerIndexes.includes(1)));
341
+ element.addEventListener("click", () => {
342
+ if (typeof config.onMatchClick === "function") config.onMatchClick(match);
343
+ });
344
+ return element;
345
+ }
346
+ function createPlayerElement(player, className, isWinner) {
347
+ const element = document.createElement("div");
348
+ element.classList.add("tournament-bracket-player", className);
349
+ if (isWinner) element.classList.add("player-winner");
350
+ element.style.minWidth = `${config.width}px`;
351
+ const avatarAndNameContainerElement = document.createElement("div");
352
+ avatarAndNameContainerElement.className = "avatar-and-name-container";
353
+ const avatarElement = document.createElement("img");
354
+ avatarElement.className = "avatar";
355
+ avatarElement.src = player.avatarUrl || config.avatarFallbackUrl;
356
+ avatarElement.alt = player.name || "Player avatar";
357
+ const nameElement = document.createElement("div");
358
+ nameElement.className = "name";
359
+ nameElement.textContent = player.name;
360
+ const scoreElement = document.createElement("div");
361
+ scoreElement.className = "score";
362
+ scoreElement.textContent = player.score;
363
+ avatarAndNameContainerElement.appendChild(avatarElement);
364
+ avatarAndNameContainerElement.appendChild(nameElement);
365
+ element.appendChild(avatarAndNameContainerElement);
366
+ element.appendChild(scoreElement);
367
+ return element;
368
+ }
369
+ function findMatchById(matchId) {
370
+ if (!bracketState) return null;
371
+ for (const round of bracketState.rounds) {
372
+ const foundMatch = round.matches.find((match) => match.matchId === matchId);
373
+ if (foundMatch) return foundMatch;
374
+ }
375
+ return null;
376
+ }
377
+ function init() {
378
+ const container = validateConfig();
379
+ bracketState = buildBracketModel(config.rounds);
380
+ resolveBracket();
381
+ renderBracket(container, createBracketLayout(bracketState));
382
+ }
383
+ function updateMatch(matchId, nextMatchData) {
384
+ if (!bracketState) throw new Error("Tournament bracket is not initialized");
385
+ let updatedRoundIndex = -1;
386
+ let updatedMatchIndex = -1;
387
+ bracketState.rounds = bracketState.rounds.map((round, roundIndex) => ({
388
+ ...round,
389
+ matches: round.matches.map((match, matchIndex) => {
390
+ if (match.matchId !== matchId) return match;
391
+ updatedRoundIndex = roundIndex;
392
+ updatedMatchIndex = matchIndex;
393
+ return mergeMatchData(match, {
394
+ ...nextMatchData,
395
+ matchId
396
+ });
397
+ })
398
+ }));
399
+ if (updatedRoundIndex === -1 || updatedMatchIndex === -1) throw new Error(`Match with id "${matchId}" not found`);
400
+ resolveBracket();
401
+ renderBracket(validateConfig(), createBracketLayout(bracketState));
402
+ const updatedMatch = findMatchById(matchId);
403
+ if (typeof config.onMatchUpdate === "function" && updatedMatch) config.onMatchUpdate(updatedMatch);
404
+ return updatedMatch;
405
+ }
406
+ function setMatchScore(matchId, score1, score2) {
407
+ return updateMatch(matchId, { players: [{ score: score1 }, { score: score2 }] });
408
+ }
409
+ function finishMatch(matchId, score1, score2) {
410
+ const updatedMatch = updateMatch(matchId, {
411
+ isFinished: true,
412
+ status: "completed",
413
+ players: [{ score: score1 }, { score: score2 }]
414
+ });
415
+ const matchState = getMatchState(updatedMatch);
416
+ if (matchState.status !== "completed") throw new Error(`Match "${matchId}" cannot be finished because its state is "${matchState.status}".`);
417
+ if (typeof config.onMatchFinish === "function") config.onMatchFinish(updatedMatch);
418
+ return updatedMatch;
419
+ }
420
+ function destroy() {
421
+ const container = document.getElementById(config.targetId);
422
+ if (container) clearContainer(container);
423
+ bracketState = null;
424
+ }
425
+ function getState() {
426
+ if (typeof structuredClone === "function") return structuredClone(bracketState);
427
+ return JSON.parse(JSON.stringify(bracketState));
428
+ }
429
+ return {
430
+ init,
431
+ updateMatch,
432
+ setMatchScore,
433
+ finishMatch,
434
+ destroy,
435
+ getState,
436
+ onMatchClick(fn) {
437
+ config.onMatchClick = fn;
438
+ },
439
+ onMatchUpdate(fn) {
440
+ config.onMatchUpdate = fn;
441
+ },
442
+ onMatchFinish(fn) {
443
+ config.onMatchFinish = fn;
444
+ }
445
+ };
446
+ }
447
+ //#endregion
448
+ return tournamentBracket;
449
+ });
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap";body{background-color:#1e1e1e}#tournament-bracket{overflow:auto}.tournament-bracket-container{position:relative}.tournament-bracket-connectors{position:absolute;inset:0;z-index:0}.tournament-bracket-column{position:absolute;z-index:1}.match-container{position:absolute;z-index:1}.tournament-bracket-connector{transition:stroke .2s ease}.tournament-bracket-container{display:flex;gap:24px}.tournament-bracket-container .tournament-bracket-column{display:flex;flex-direction:column;align-items:center;gap:40px;justify-content:space-around}.tournament-bracket-container .tournament-bracket-column .match-container{position:relative;border-radius:10px}.tournament-bracket-container .tournament-bracket-column .match-container .player-vs{position:absolute;top:calc(50% + 10px);left:0;right:0;transform:translateY(-50%);margin:0 auto;width:20px;height:20px;display:flex;align-items:center;justify-content:center;border-radius:50%;border:1px solid #ff6e27;background-color:#464646;color:#f8f9fa;font-family:Roboto;font-size:12px;font-style:normal;font-weight:700;line-height:normal;z-index:1}.tournament-bracket-container .tournament-bracket-column .match-container .tournament-bracket-player{display:flex;align-items:center;justify-content:space-between;height:47px;padding:0 16px;background-color:#464646;color:#949494;font-family:Roboto;font-size:14px;font-style:normal;font-weight:600;line-height:normal;box-sizing:border-box}.tournament-bracket-container .tournament-bracket-column .match-container .tournament-bracket-player .avatar-and-name-container{display:flex;gap:8px;align-items:center}.tournament-bracket-container .tournament-bracket-column .match-container .tournament-bracket-player.player-winner{background-color:#111}.tournament-bracket-container .tournament-bracket-column .match-container .tournament-bracket-player .avatar{width:32px;height:32px;border-radius:100%;overflow:hidden}.tournament-bracket-container .tournament-bracket-column .match-container .tournament-bracket-player .name{max-width:92px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tournament-bracket-container .tournament-bracket-column .match-container .tournament-bracket-player .score{font-family:Roboto;padding:0 12px;font-size:20px;font-style:normal;font-weight:500;line-height:normal;color:#f8f9fa}/*# sourceMappingURL=tournament-bracket.min.css.map */
@@ -0,0 +1 @@
1
+ {"version":3,"sourceRoot":"","sources":["../src/tournament-bracket.scss"],"names":[],"mappings":"AAAQ,uFAER,KACE,yBAGF,oBACE,cAGF,8BACE,kBAGF,+BACE,kBACA,QACA,UAGF,2BACE,kBACA,UAGF,iBACE,kBACA,UAGF,8BACE,2BAGF,8BACE,aACA,SAEA,yDACE,aACA,sBACA,mBACA,SACA,6BAEA,0EACE,kBACA,mBA8BA,qFACE,kBACA,qBACA,OACA,QACA,2BACA,cACA,WACA,YACA,aACA,mBACA,uBACA,kBACA,yBACA,yBACA,cACA,mBACA,eACA,kBACA,gBACA,mBACA,UAGF,qGACE,aACA,mBACA,8BACA,YACA,eACA,yBACA,cACA,mBACA,eACA,kBACA,gBACA,mBACA,sBAEA,gIACE,aACA,QACA,mBAGF,mHACE,sBAGF,6GACE,WACA,YACA,mBACA,gBAGF,2GACE,eACA,gBACA,uBACA,mBAGF,4GACE,mBACA,eACA,eACA,kBACA,gBACA,mBACA","file":"tournament-bracket.min.css"}
@@ -0,0 +1 @@
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):(t="undefined"!=typeof globalThis?globalThis:t||self).tournamentBracket=e()}(this,function(){return function(t={}){const e={name:"-",avatarUrl:"",score:"-"},n={targetId:"tournament-bracket",rounds:null,distance:42,width:196,matchHeight:94,roundGap:96,avatarFallbackUrl:"data:image/svg+xml;utf8,"+encodeURIComponent('\n <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">\n <rect width="100%" height="100%" fill="#e5e7eb"/>\n <text x="50%" y="54%" text-anchor="middle" font-size="28" fill="#6b7280">?</text>\n </svg>\n '),connectorColor:"white",onMatchClick:null,onMatchUpdate:null,onMatchFinish:null},r=function(t){return{...n,...t,rounds:t.rounds??[]}}(t);let o=null;function a(){const t=document.getElementById(r.targetId);if(!t)throw new Error(`Element with id "${r.targetId}" not found`);return function(t){if(!Array.isArray(t)||0===t.length)throw new Error("rounds must be a non-empty array.");t.forEach((t,e)=>{if(!t||"object"!=typeof t)throw new Error(`Round at index ${e} must be an object.`);if(!Array.isArray(t.matches))throw new Error(`rounds[${e}].matches must be an array.`);if(0===t.matches.length)throw new Error(`rounds[${e}].matches must not be empty.`);t.matches.forEach((t,n)=>{if(!t||"object"!=typeof t)throw new Error(`rounds[${e}].matches[${n}] must be an object.`);if(!Array.isArray(t.players))throw new Error(`rounds[${e}].matches[${n}].players must be an array.`);if(2!==t.players.length)throw new Error(`rounds[${e}].matches[${n}] must have exactly 2 players.`)})});const e=t[0].matches.length;if(!((n=e)>0)||n&n-1)throw new Error("First round matches count must be a power of two.");var n;for(let r=1;r<t.length;r++){const e=t[r-1].matches.length/2,n=t[r].matches.length;if(n!==e)throw new Error(`Invalid bracket shape: rounds[${r}] must have ${e} matches, but got ${n}.`)}if(1!==t[t.length-1].matches.length)throw new Error("Last round must have exactly 1 match.")}(r.rounds),t}function s(t){return window.crypto?.randomUUID?`${t}-${window.crypto.randomUUID()}`:`${t}-${Math.random().toString(36).slice(2,9)}`}function c(t){const n="boolean"==typeof t.isFinished?t.isFinished:"completed"===t.status;return{matchId:t.matchId||s("match"),status:t.status||(n?"completed":"pending"),isFinished:n,players:[{...e,...t.players?.[0]},{...e,...t.players?.[1]}]}}function i(t){return"-"===t||""===t||null==t}function d(t){const[e,n]=t.players,r=i(e.score),o=i(n.score);if(r&&o)return{status:"pending",winnerIndexes:[]};const a=Number(e.score),s=Number(n.score);return Number.isNaN(a)||Number.isNaN(s)?{status:"invalid-score",winnerIndexes:[]}:a===s?{status:"draw",winnerIndexes:[]}:t.isFinished?{status:"completed",winnerIndexes:[a>s?0:1]}:{status:"in-progress",winnerIndexes:[]}}function u(t={}){return t.id||t.name||""}function h(t,n){const r=o.rounds[t]?.matches?.[n];if(!r)return;const a=d(r),s=o.rounds[t+1];if(!s)return;const c=Math.floor(n/2),i=s.matches[c];if(!i)return;const h=n%2,l="completed"===a.status?r.players[a.winnerIndexes[0]]:null;i.players[h]=l?function(t,e={},n){const r=u(t),o=u(e),a=r&&o&&r===o;return{name:t.name??"",avatarUrl:t.avatarUrl??"",seed:t.seed,id:t.id,score:a?e.score??"-":"-",previousMatchId:n}}(l,i.players[h],r.matchId):{...e}}function l(){for(let t=0;t<o.rounds.length-1;t++)o.rounds[t].matches.forEach((e,n)=>{h(t,n)})}function m(t){const e=r.width,n=r.roundGap,o=r.matchHeight,a=r.distance,s=t.rounds.map((t,r)=>{const s=r*(e+n);return{...t,x:s,matches:t.matches.map((t,n)=>({...t,x:s,y:n*(o+a),width:e,height:o}))}});for(let r=1;r<s.length;r++){const t=s[r-1],e=s[r];e.matches=e.matches.map((e,n)=>{const r=t.matches[2*n],a=t.matches[2*n+1];return{...e,y:(p(r)+p(a))/2-o/2}})}return{rounds:s,connectors:f(s),width:y(s),height:w(s)}}function p(t){return t.y+t.height/2}function f(t){const e=[];for(let n=0;n<t.length-1;n++){const r=t[n],o=t[n+1];r.matches.forEach((t,n)=>{const r=o.matches[Math.floor(n/2)];r&&e.push({id:s("connector"),from:{x:t.x+t.width,y:p(t)},to:{x:r.x,y:p(r)}})})}return e}function y(t){const e=t[t.length-1].matches[0];return e.x+e.width}function w(t){return Math.max(...t.flatMap(t=>t.matches.map(t=>t.y+t.height)))}function g(t){t.textContent=""}function x(t,e){g(t);const n=document.createElement("div");n.className="tournament-bracket-container",n.style.position="relative",n.style.width=`${e.width}px`,n.style.height=`${e.height}px`;const o=function(t){const e=document.createElementNS("http://www.w3.org/2000/svg","svg");return e.classList.add("tournament-bracket-connectors"),e.setAttribute("width",String(t.width)),e.setAttribute("height",String(t.height)),e.setAttribute("viewBox",`0 0 ${t.width} ${t.height}`),e.style.position="absolute",e.style.inset="0",e.style.overflow="visible",e.style.pointerEvents="none",e.style.zIndex="0",t.connectors.forEach(t=>{e.appendChild(function(t){const e=document.createElementNS("http://www.w3.org/2000/svg","path"),n=t.from.x+(t.to.x-t.from.x)/2,o=[`M ${t.from.x} ${t.from.y}`,`H ${n}`,`V ${t.to.y}`,`H ${t.to.x}`].join(" ");return e.setAttribute("d",o),e.setAttribute("fill","none"),e.setAttribute("stroke",r.connectorColor),e.setAttribute("stroke-width","1"),e.setAttribute("stroke-linejoin","round"),e.setAttribute("stroke-linecap","round"),e.classList.add("tournament-bracket-connector"),e}(t))}),e}(e);n.appendChild(o),e.rounds.forEach(t=>{n.appendChild(function(t){const e=document.createElement("div");return e.className="tournament-bracket-column",e.dataset.roundId=t.roundId,e.dataset.roundName=t.name,e.dataset.roundIndex=String(t.roundIndex),e.style.position="absolute",e.style.left=`${t.x}px`,e.style.top="0",e.style.width=`${r.width}px`,e.style.zIndex="1",t.matches.forEach(t=>{e.appendChild(function(t){const e=document.createElement("div"),n=d(t);e.id=t.matchId,e.className="match-container",e.dataset.matchStatus=n.status,e.style.position="absolute",e.style.top=`${t.y}px`,e.style.left="0",e.style.width=`${t.width}px`,e.style.height=`${t.height}px`;const o=document.createElement("div");return o.className="player-vs",o.textContent="VS",e.appendChild(o),e.appendChild(b(t.players[0],"tournament-bracket-first-player",n.winnerIndexes.includes(0))),e.appendChild(b(t.players[1],"tournament-bracket-second-player",n.winnerIndexes.includes(1))),e.addEventListener("click",()=>{"function"==typeof r.onMatchClick&&r.onMatchClick(t)}),e}(t))}),e}(t))}),t.appendChild(n)}function b(t,e,n){const o=document.createElement("div");o.classList.add("tournament-bracket-player",e),n&&o.classList.add("player-winner"),o.style.minWidth=`${r.width}px`;const a=document.createElement("div");a.className="avatar-and-name-container";const s=document.createElement("img");s.className="avatar",s.src=t.avatarUrl||r.avatarFallbackUrl,s.alt=t.name||"Player avatar";const c=document.createElement("div");c.className="name",c.textContent=t.name;const i=document.createElement("div");return i.className="score",i.textContent=t.score,a.appendChild(s),a.appendChild(c),o.appendChild(a),o.appendChild(i),o}function v(t,e){if(!o)throw new Error("Tournament bracket is not initialized");let n=-1,s=-1;if(o.rounds=o.rounds.map((r,o)=>({...r,matches:r.matches.map((r,a)=>r.matchId!==t?r:(n=o,s=a,function(t,e){const n=e.players||[];return c({...t,...e,players:[{...t.players?.[0],...n[0]},{...t.players?.[1],...n[1]}]})}(r,{...e,matchId:t})))})),-1===n||-1===s)throw new Error(`Match with id "${t}" not found`);l(),x(a(),m(o));const i=function(t){if(!o)return null;for(const e of o.rounds){const n=e.matches.find(e=>e.matchId===t);if(n)return n}return null}(t);return"function"==typeof r.onMatchUpdate&&i&&r.onMatchUpdate(i),i}return{init:function(){const t=a();var e;e=r.rounds,o={rounds:e.map((t,e)=>{const n=t.matches.map((t,n)=>{const r=c(t);return r.roundIndex=e,r.matchIndex=n,r});return{roundId:t.roundId||s("round"),name:t.name||`Round ${e+1}`,roundIndex:e,matches:n}})},l(),x(t,m(o))},updateMatch:v,setMatchScore:function(t,e,n){return v(t,{players:[{score:e},{score:n}]})},finishMatch:function(t,e,n){const o=v(t,{isFinished:!0,status:"completed",players:[{score:e},{score:n}]}),a=d(o);if("completed"!==a.status)throw new Error(`Match "${t}" cannot be finished because its state is "${a.status}".`);return"function"==typeof r.onMatchFinish&&r.onMatchFinish(o),o},destroy:function(){const t=document.getElementById(r.targetId);t&&g(t),o=null},getState:function(){return"function"==typeof structuredClone?structuredClone(o):JSON.parse(JSON.stringify(o))},onMatchClick(t){r.onMatchClick=t},onMatchUpdate(t){r.onMatchUpdate=t},onMatchFinish(t){r.onMatchFinish=t}}}});
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@amirhossein-shk/tournament-bracket-js",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight vanilla JS tournament bracket library",
5
+ "main": "tournamentBracket.js",
6
+ "module": "dist/tournament-bracket.esm.js",
7
+ "style": "dist/tournament-bracket.css",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "build": "npm run clean && npm run browser && npm run browser:min && npm run esm && npm run esm:min && npm run css && npm run css:min && npm run clean:temp",
15
+ "clean": "shx rm -rf dist dist-temp",
16
+ "clean:temp": "shx rm -rf dist-temp",
17
+ "browser": "vite build --mode browser",
18
+ "browser:min": "vite build --mode browser-min --outDir dist-temp && shx cp dist-temp/tournament-bracket.js dist/tournament-bracket.min.js",
19
+ "esm": "vite build --mode esm",
20
+ "esm:min": "vite build --mode esm-min --outDir dist-temp && shx cp dist-temp/tournament-bracket.esm.js dist/tournament-bracket.esm.min.js",
21
+ "css": "sass src/tournament-bracket.scss dist/tournament-bracket.css --style expanded",
22
+ "css:min": "sass src/tournament-bracket.scss dist/tournament-bracket.min.css --style compressed"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/amirhossein-shk/tournament-bracket.git"
27
+ },
28
+ "keywords": [
29
+ "tournament",
30
+ "bracket",
31
+ "javascript",
32
+ "vanilla-js",
33
+ "single-elimination"
34
+ ],
35
+ "author": "",
36
+ "type": "commonjs",
37
+ "bugs": {
38
+ "url": "https://github.com/amirhossein-shk/tournament-bracket/issues"
39
+ },
40
+ "homepage": "https://github.com/amirhossein-shk/tournament-bracket#readme",
41
+ "devDependencies": {
42
+ "shx": "^0.4.0",
43
+ "terser": "^5.48.0",
44
+ "vite": "^8.0.16",
45
+ "sass": "^1.101.0"
46
+ },
47
+ "license": "MIT"
48
+ }