@ethlete/bracket 0.0.1 → 1.0.0-next.2

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,3635 @@
1
+ const BRACKET_ERROR_CODES = {
2
+ /** The source has no rounds/matches to render. */
3
+ SOURCE_EMPTY: 3401,
4
+ /** An integration received a tournament mode it does not support. */
5
+ MODE_UNSUPPORTED: 3402,
6
+ /** Two rounds in the source share an id. */
7
+ DUPLICATE_ROUND: 3403,
8
+ /** Two matches in the source share an id. */
9
+ DUPLICATE_MATCH: 3404,
10
+ /** A round-to-round relation could not be resolved (malformed round structure). */
11
+ ROUND_RELATION_INVALID: 3405,
12
+ /** A match-to-match relation could not be resolved (match counts don't line up). */
13
+ MATCH_RELATION_INVALID: 3406,
14
+ /** The computed layout grid is in an inconsistent state. */
15
+ GRID_INVALID: 3407,
16
+ /** Swiss groups could not be generated from the source. */
17
+ SWISS_GROUPING_FAILED: 3408,
18
+ /** A Swiss group ended up without any matches while headers are enabled. */
19
+ SWISS_GROUP_EMPTY: 3409,
20
+ /** A match's resolved winner id is not among its participants. */
21
+ WINNER_NOT_FOUND: 3410,
22
+ /** A required key was missing from an internal bracket lookup map. */
23
+ DATA_LOOKUP_FAILED: 3411,
24
+ /** The default cards are rendering but no `matchNormalizer` was registered to feed them. */
25
+ MISSING_MATCH_NORMALIZER: 3412,
26
+ /** No registered {@link BracketLayout} matches the source's tournament `mode`. */
27
+ LAYOUT_NOT_REGISTERED: 3413,
28
+ /** A cell the bracket draws has no card - neither an input, the layout, nor the config names one. */
29
+ CARD_NOT_REGISTERED: 3414,
30
+ };
31
+
32
+ class BracketRuntimeError extends Error {
33
+ constructor(code, message) {
34
+ super(`ET${code}: ${message}`);
35
+ this.name = 'BracketRuntimeError';
36
+ }
37
+ }
38
+
39
+ class BracketMap extends Map {
40
+ constructor() {
41
+ super();
42
+ }
43
+ getOrThrow(key) {
44
+ const value = super.get(key);
45
+ if (value === undefined) {
46
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.DATA_LOOKUP_FAILED, `Value for key ${key} not found in bracket map`);
47
+ }
48
+ return value;
49
+ }
50
+ first() {
51
+ return this.values().next().value;
52
+ }
53
+ last() {
54
+ const values = Array.from(this.values());
55
+ return values.length > 0 ? values[values.length - 1] : undefined;
56
+ }
57
+ }
58
+
59
+ const BRACKET_DATA_LAYOUT = {
60
+ LEFT_TO_RIGHT: 'left-to-right',
61
+ /**
62
+ * Folded in half: each round up to the first one too small to halve is drawn twice, once on each
63
+ * side, converging on that round and the ones after it. Halves the height and roughly doubles the
64
+ * width, which is what a poster or a broadcast graphic wants.
65
+ *
66
+ * Carried by the mirrored layout factories - a layout that has no fold to make (swiss) simply has no
67
+ * mirrored variant.
68
+ */
69
+ MIRRORED: 'mirrored',
70
+ };
71
+
72
+ const TOURNAMENT_MODE = {
73
+ SINGLE_ELIMINATION: 'single-elimination',
74
+ DOUBLE_ELIMINATION: 'double-elimination',
75
+ SWISS_WITH_ELIMINATION: 'swiss-with-elimination',
76
+ };
77
+ /** Wins that advance a participant out of a swiss stage. */
78
+ const SWISS_ADVANCE_WINS = 3;
79
+ /** Losses that eliminate a participant from a swiss stage. */
80
+ const SWISS_ELIMINATE_LOSSES = 3;
81
+
82
+ const BRACKET_ROUND_MIRROR_TYPE = {
83
+ LEFT: 'left',
84
+ RIGHT: 'right',
85
+ };
86
+ const COMMON_BRACKET_ROUND_TYPE = {
87
+ THIRD_PLACE: 'third-place',
88
+ FINAL: 'final',
89
+ };
90
+ const SINGLE_ELIMINATION_BRACKET_ROUND_TYPE = {
91
+ SINGLE_ELIMINATION_BRACKET: 'single-elimination-bracket',
92
+ };
93
+ const DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE = {
94
+ UPPER_BRACKET: 'upper-bracket',
95
+ LOWER_BRACKET: 'lower-bracket',
96
+ REVERSE_FINAL: 'reverse-final',
97
+ };
98
+ const SWISS_BRACKET_ROUND_TYPE = {
99
+ SWISS: 'swiss',
100
+ };
101
+ const GROUP_BRACKET_ROUND_TYPE = {
102
+ GROUP: 'group',
103
+ };
104
+ const TERMINAL_ROUND_SORT_PRIORITY = {
105
+ [COMMON_BRACKET_ROUND_TYPE.FINAL]: 1,
106
+ [DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL]: 2,
107
+ [COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE]: 3,
108
+ };
109
+ const createRoundsMapBase = (source, options) => {
110
+ const map = new BracketMap();
111
+ const shouldSplitRoundsInTwo = options.layout === BRACKET_DATA_LAYOUT.MIRRORED;
112
+ const isDoubleElimination = source.mode === TOURNAMENT_MODE.DOUBLE_ELIMINATION;
113
+ let currentUpperBracketIndex = 0;
114
+ let currentLowerBracketIndex = 0;
115
+ // A fold converges once, on the first round it cannot halve, and every later round stays whole -
116
+ // otherwise a bye (`[6, 3, 2, 1]`) reopens the fold past its middle and draws a round on both sides
117
+ // of one that has no side. Tracked per bracket half, because the two converge separately.
118
+ const foldClosed = { upper: false, lower: false };
119
+ const splitRoundsRest = [];
120
+ const orderedRounds = [...source.rounds].sort((a, b) => (TERMINAL_ROUND_SORT_PRIORITY[a.type] ?? 0) - (TERMINAL_ROUND_SORT_PRIORITY[b.type] ?? 0));
121
+ for (const [roundIndex, round] of orderedRounds.entries()) {
122
+ const isUpperBracket = round.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.UPPER_BRACKET;
123
+ const isLowerBracket = round.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET;
124
+ const isCommonDoubleEliminationRound = !isUpperBracket && !isLowerBracket && isDoubleElimination;
125
+ const matches = source.matches.filter((m) => m.roundId === round.id);
126
+ const roundId = round.id;
127
+ const foldSide = isLowerBracket || isCommonDoubleEliminationRound ? 'lower' : 'upper';
128
+ const canHalveRound = matches.length >= 2 && matches.length % 2 === 0;
129
+ const shouldSplitRound = shouldSplitRoundsInTwo && canHalveRound && !foldClosed[foldSide];
130
+ if (!canHalveRound)
131
+ foldClosed[foldSide] = true;
132
+ const isFirstRound = roundIndex === 0;
133
+ const isLastRound = roundIndex === orderedRounds.length - 1;
134
+ const isFirstOfType = orderedRounds.findIndex((r) => r.type === round.type) === roundIndex;
135
+ const isLastOfType = orderedRounds
136
+ .slice()
137
+ .reverse()
138
+ .findIndex((r) => r.type === round.type) ===
139
+ orderedRounds.length - roundIndex - 1;
140
+ const depth = isLowerBracket || isCommonDoubleEliminationRound ? currentLowerBracketIndex : currentUpperBracketIndex;
141
+ if (shouldSplitRound) {
142
+ const firstHalfRoundId = `${roundId}--half-1`;
143
+ const secondHalfRoundId = `${roundId}--half-2`;
144
+ const firstHalfMatchesMaxIndex = matches.length / 2 - 1;
145
+ // Both halves are the same round at the same depth, drawn on either side of the fold - so they
146
+ // carry the same `logicalIndex`, which is what pairs a right-hand column with its left-hand twin.
147
+ const halfBase = {
148
+ type: round.type,
149
+ logicalIndex: depth,
150
+ data: round.data,
151
+ position: (depth + 1),
152
+ name: round.name,
153
+ matchCount: matches.length / 2,
154
+ isFirstRound,
155
+ isLastRound,
156
+ isFirstOfType,
157
+ isLastOfType,
158
+ };
159
+ const bracketRoundFirstHalf = {
160
+ ...halfBase,
161
+ id: firstHalfRoundId,
162
+ shortId: `${roundIndex}-left`,
163
+ matchIds: matches.slice(0, firstHalfMatchesMaxIndex + 1).map((m) => m.id),
164
+ mirrorRoundType: BRACKET_ROUND_MIRROR_TYPE.LEFT,
165
+ };
166
+ const bracketRoundSecondHalf = {
167
+ ...halfBase,
168
+ id: secondHalfRoundId,
169
+ shortId: `${roundIndex}-right`,
170
+ matchIds: matches.slice(firstHalfMatchesMaxIndex + 1).map((m) => m.id),
171
+ mirrorRoundType: BRACKET_ROUND_MIRROR_TYPE.RIGHT,
172
+ };
173
+ map.set(firstHalfRoundId, bracketRoundFirstHalf);
174
+ splitRoundsRest.unshift(bracketRoundSecondHalf);
175
+ }
176
+ else {
177
+ const bracketRound = {
178
+ type: round.type,
179
+ id: roundId,
180
+ shortId: `${roundIndex}`,
181
+ logicalIndex: depth,
182
+ data: round.data,
183
+ position: (depth + 1),
184
+ name: round.name,
185
+ matchCount: matches.length,
186
+ matchIds: matches.map((m) => m.id),
187
+ mirrorRoundType: null,
188
+ isFirstRound,
189
+ isLastRound,
190
+ isFirstOfType,
191
+ isLastOfType,
192
+ };
193
+ map.set(roundId, bracketRound);
194
+ }
195
+ if (isLowerBracket || isCommonDoubleEliminationRound) {
196
+ currentLowerBracketIndex++;
197
+ }
198
+ else {
199
+ currentUpperBracketIndex++;
200
+ }
201
+ }
202
+ // The right halves close the map, deepest first, so walking it in order walks the fold: out along the
203
+ // left side, through the rounds too small to halve, and back along the right. `splitRoundsRest` was
204
+ // built by unshifting, so it is already in that order - and each half keeps the depth of its twin
205
+ // rather than being renumbered past the last round, which is what lets a builder pair the two sides.
206
+ if (splitRoundsRest.length) {
207
+ if (!map.last())
208
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.ROUND_RELATION_INVALID, 'Last round not found');
209
+ for (const splitRound of splitRoundsRest) {
210
+ map.set(splitRound.id, splitRound);
211
+ }
212
+ }
213
+ return map;
214
+ };
215
+
216
+ const createNewMatchParticipantBase = (source, participantId, match, rounds, matchRoundId, participants, sourceMatchesById) => {
217
+ if (!participantId)
218
+ return null;
219
+ const findSourceMatch = (matchId) => matchId === undefined
220
+ ? undefined
221
+ : sourceMatchesById
222
+ ? sourceMatchesById.get(matchId)
223
+ : source.matches.find((m) => m.id === matchId);
224
+ const participantBase = participants.getOrThrow(participantId);
225
+ const roundBase = rounds.getOrThrow(matchRoundId);
226
+ const matchIndex = participantBase.matchIds.indexOf(match.id);
227
+ if (matchIndex === -1)
228
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, `Match with id ${match.id} not found in participant with id ${participantId}`);
229
+ const participantSide = match.home === participantId ? 'home' : 'away';
230
+ const isWinner = match.winner === participantSide;
231
+ const isLooser = match.winner && match.winner !== participantSide;
232
+ const isTie = match.status === 'completed' && !match.winner;
233
+ let winsTilNow = isWinner ? 1 : 0;
234
+ let lossesTilNow = isLooser ? 1 : 0;
235
+ let tiesTilNow = isTie ? 1 : 0;
236
+ for (let i = 0; i < matchIndex; i++) {
237
+ const previousMatchId = participantBase.matchIds[i];
238
+ const previousMatch = findSourceMatch(previousMatchId);
239
+ const myPreviousMatchSide = previousMatch?.home === participantId ? 'home' : previousMatch?.away === participantId ? 'away' : null;
240
+ if (!previousMatch || !myPreviousMatchSide)
241
+ continue;
242
+ const previousIsWinner = previousMatch.winner === myPreviousMatchSide;
243
+ const previousIsLooser = previousMatch.winner && previousMatch.winner !== myPreviousMatchSide;
244
+ const previousIsTie = previousMatch.status === 'completed' && !previousMatch.winner;
245
+ if (previousIsWinner) {
246
+ winsTilNow++;
247
+ }
248
+ else if (previousIsLooser) {
249
+ lossesTilNow++;
250
+ }
251
+ else if (previousIsTie) {
252
+ tiesTilNow++;
253
+ }
254
+ }
255
+ const hasElimination = source.mode === TOURNAMENT_MODE.SINGLE_ELIMINATION ||
256
+ source.mode === TOURNAMENT_MODE.DOUBLE_ELIMINATION ||
257
+ source.mode === TOURNAMENT_MODE.SWISS_WITH_ELIMINATION;
258
+ let isEliminated = false;
259
+ let isEliminationMatch = false;
260
+ if (hasElimination) {
261
+ switch (source.mode) {
262
+ case TOURNAMENT_MODE.SINGLE_ELIMINATION: {
263
+ isEliminationMatch = true;
264
+ isEliminated = isLooser ?? false;
265
+ break;
266
+ }
267
+ case TOURNAMENT_MODE.DOUBLE_ELIMINATION: {
268
+ isEliminationMatch =
269
+ roundBase.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET ||
270
+ roundBase.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL ||
271
+ roundBase.type === COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE;
272
+ // Final is only an elimination match if there is no reverse final or there is one and this participant came from the lower bracket
273
+ if (roundBase.type === COMMON_BRACKET_ROUND_TYPE.FINAL) {
274
+ const hasReverseFinal = source.rounds.some((r) => r.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL);
275
+ if (!hasReverseFinal)
276
+ break;
277
+ const currentMatchIndex = participantBase.matchIds.indexOf(match.id);
278
+ if (currentMatchIndex === -1)
279
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, `Match with id ${match.id} not found in participant with id ${participantId}`);
280
+ if (currentMatchIndex === 0)
281
+ break;
282
+ const previousMatchIndex = currentMatchIndex - 1;
283
+ const previousMatchId = participantBase.matchIds[previousMatchIndex];
284
+ const previousMatch = findSourceMatch(previousMatchId);
285
+ if (!previousMatch)
286
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, `Previous match with id ${previousMatchId} not found`);
287
+ const previousRound = rounds.getOrThrow(previousMatch.roundId);
288
+ isEliminationMatch = previousRound.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET;
289
+ }
290
+ isEliminated = (isEliminationMatch && isLooser) ?? false;
291
+ break;
292
+ }
293
+ case TOURNAMENT_MODE.SWISS_WITH_ELIMINATION: {
294
+ // `lossesTilNow` counts this match too, so the losses carried *into* it are one fewer for a
295
+ // loser - it is those that decide whether losing here is what puts the participant out.
296
+ const priorLosses = lossesTilNow - (isLooser ? 1 : 0);
297
+ isEliminationMatch = priorLosses >= SWISS_ELIMINATE_LOSSES - 1;
298
+ isEliminated = (isEliminationMatch && isLooser) ?? false;
299
+ }
300
+ }
301
+ }
302
+ const matchParticipantBase = {
303
+ ...participantBase,
304
+ isEliminated,
305
+ isEliminationMatch,
306
+ lossCount: lossesTilNow,
307
+ tieCount: tiesTilNow,
308
+ winCount: winsTilNow,
309
+ result: isWinner ? 'win' : isLooser ? 'loss' : isTie ? 'tie' : null,
310
+ side: participantSide,
311
+ };
312
+ return matchParticipantBase;
313
+ };
314
+
315
+ const createMatchesMapBase = (source, rounds, participants) => {
316
+ const map = new BracketMap();
317
+ const sourceMatchesById = new Map(source.matches.map((match) => [match.id, match]));
318
+ for (const match of source.matches) {
319
+ const genericRound = rounds.get(match.roundId);
320
+ const splitRound = rounds.get(`${match.roundId}--half-1`);
321
+ const splitRound2 = rounds.get(`${match.roundId}--half-2`);
322
+ const genericRoundMatchIndex = genericRound?.matchIds.indexOf(match.id) ?? -1;
323
+ const splitRoundMatchIndex = splitRound?.matchIds.indexOf(match.id) ?? -1;
324
+ const splitRound2MatchIndex = splitRound2?.matchIds.indexOf(match.id) ?? -1;
325
+ const roundToUse = genericRoundMatchIndex !== -1
326
+ ? genericRound
327
+ : splitRoundMatchIndex !== -1
328
+ ? splitRound
329
+ : splitRound2MatchIndex !== -1
330
+ ? splitRound2
331
+ : null;
332
+ if (!roundToUse)
333
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, `Round for match with id ${match.id} not found`);
334
+ const indexInRound = genericRoundMatchIndex !== -1
335
+ ? genericRoundMatchIndex
336
+ : splitRoundMatchIndex !== -1
337
+ ? splitRoundMatchIndex
338
+ : splitRound2MatchIndex !== -1
339
+ ? splitRound2MatchIndex
340
+ : -1;
341
+ if (indexInRound === -1)
342
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, `Match with id ${match.id} not found in round with id ${roundToUse.id}`);
343
+ const home = createNewMatchParticipantBase(source, match.home, match, rounds, roundToUse.id, participants, sourceMatchesById);
344
+ const away = createNewMatchParticipantBase(source, match.away, match, rounds, roundToUse.id, participants, sourceMatchesById);
345
+ const winner = match.winner === 'home' ? home : match.winner === 'away' ? away : null;
346
+ const matchBase = {
347
+ home,
348
+ away,
349
+ data: match.data,
350
+ shortId: `${roundToUse.shortId}-${indexInRound}`,
351
+ id: match.id,
352
+ indexInRound,
353
+ position: (indexInRound + 1),
354
+ roundId: roundToUse?.id,
355
+ status: match.status,
356
+ homeSource: match.homeSource ?? null,
357
+ awaySource: match.awaySource ?? null,
358
+ winner,
359
+ winnerSide: match.winner,
360
+ };
361
+ map.set(matchBase.id, matchBase);
362
+ }
363
+ return map;
364
+ };
365
+
366
+ const createParticipantsMapBase = (source) => {
367
+ const map = new BracketMap();
368
+ const participantIds = source.matches
369
+ .map((m) => [m.home, m.away])
370
+ .flat()
371
+ .filter((p) => !!p);
372
+ const matchIdsByParticipant = new Map();
373
+ for (const match of source.matches) {
374
+ for (const participantId of new Set([match.home, match.away])) {
375
+ if (!participantId)
376
+ continue;
377
+ const matchIds = matchIdsByParticipant.get(participantId) ?? [];
378
+ matchIds.push(match.id);
379
+ matchIdsByParticipant.set(participantId, matchIds);
380
+ }
381
+ }
382
+ for (const [index, participantId] of participantIds.entries()) {
383
+ const participantBase = {
384
+ id: participantId,
385
+ shortId: `p${index}`,
386
+ matchIds: matchIdsByParticipant.get(participantId) ?? [],
387
+ };
388
+ map.set(participantId, participantBase);
389
+ }
390
+ return map;
391
+ };
392
+
393
+ const sortSourceMatchesByRoundOrder = (source) => {
394
+ const orderedRoundIds = [...source.rounds]
395
+ .sort((a, b) => (TERMINAL_ROUND_SORT_PRIORITY[a.type] ?? 0) - (TERMINAL_ROUND_SORT_PRIORITY[b.type] ?? 0))
396
+ .map((r) => r.id);
397
+ const roundIndexMap = new Map(orderedRoundIds.map((id, i) => [id, i]));
398
+ const sortedMatches = [...source.matches].sort((a, b) => (roundIndexMap.get(a.roundId) ?? 0) - (roundIndexMap.get(b.roundId) ?? 0));
399
+ return { ...source, matches: sortedMatches };
400
+ };
401
+ const assertUniqueIds = (options) => {
402
+ const seen = new Set();
403
+ for (const id of options.ids) {
404
+ if (seen.has(id)) {
405
+ throw new BracketRuntimeError(options.code, `${options.kind} with id ${id} exists more than once in the source.`);
406
+ }
407
+ seen.add(id);
408
+ }
409
+ };
410
+ const createBracketBase = (source, options) => {
411
+ assertUniqueIds({
412
+ ids: source.rounds.map((round) => round.id),
413
+ code: BRACKET_ERROR_CODES.DUPLICATE_ROUND,
414
+ kind: 'Round',
415
+ });
416
+ assertUniqueIds({
417
+ ids: source.matches.map((match) => match.id),
418
+ code: BRACKET_ERROR_CODES.DUPLICATE_MATCH,
419
+ kind: 'Match',
420
+ });
421
+ const normalizedSource = sortSourceMatchesByRoundOrder(source);
422
+ const participants = createParticipantsMapBase(normalizedSource);
423
+ const rounds = createRoundsMapBase(normalizedSource, options);
424
+ const matches = createMatchesMapBase(normalizedSource, rounds, participants);
425
+ const bracketData = {
426
+ matches,
427
+ rounds,
428
+ participants,
429
+ mode: source.mode,
430
+ };
431
+ return bracketData;
432
+ };
433
+
434
+ const path = (d, options) => {
435
+ const commands = d.replace(/\s+/g, ' ').trim();
436
+ return {
437
+ id: options.id,
438
+ d: commands,
439
+ cssPath: `path("${commands}")`,
440
+ cssClass: options.className,
441
+ strokeWidth: options.width,
442
+ dashArray: options.dashArray,
443
+ dashOffset: options.dashOffset,
444
+ stroke: options.stroke ?? 'currentColor',
445
+ };
446
+ };
447
+
448
+ const curvePath = (from, to, direction, options) => {
449
+ const inverted = options.inverted ?? false;
450
+ const fromInline = inverted ? from.inline.start : from.inline.end;
451
+ const toInline = inverted ? to.inline.end : to.inline.start;
452
+ const fromBlock = from.block.center;
453
+ const toBlock = to.block.center;
454
+ // Both bends have to fit inside the block and the inline distance between the two cards, or the line
455
+ // turns past its target and comes back. Scaling them down instead - to nothing, for two cards on the
456
+ // same row - is also what keeps the six commands below identical for every connector: a CSS `d`
457
+ // transition interpolates only between two paths of the same shape.
458
+ const requestedCurve = options.lineStartingCurveAmount + options.lineEndingCurveAmount;
459
+ const totalInline = Math.abs(toInline - fromInline);
460
+ const room = Math.min(Math.abs(toBlock - fromBlock), totalInline);
461
+ const curveScale = requestedCurve > room ? room / requestedCurve : 1;
462
+ const startCurve = options.lineStartingCurveAmount * curveScale;
463
+ const endCurve = options.lineEndingCurveAmount * curveScale;
464
+ const straightLength = (totalInline - startCurve - endCurve) / 2;
465
+ const straightEnd = inverted ? fromInline - straightLength : fromInline + straightLength;
466
+ const straightStart = inverted ? toInline + straightLength : toInline - straightLength;
467
+ const firstCurveEndX = inverted ? straightEnd - startCurve : straightEnd + startCurve;
468
+ const firstCurveEndY = direction === 'down' ? fromBlock + startCurve : fromBlock - startCurve;
469
+ const secondCurveStartY = direction === 'down' ? toBlock - endCurve : toBlock + endCurve;
470
+ const secondCurveEndX = straightStart;
471
+ const secondCurveEndY = toBlock;
472
+ const secondCurveBezierX = inverted ? straightStart + endCurve : straightStart - endCurve;
473
+ const d = [
474
+ `M ${fromInline} ${fromBlock}`,
475
+ `H ${straightEnd}`,
476
+ `Q ${firstCurveEndX} ${fromBlock}, ${firstCurveEndX} ${firstCurveEndY}`,
477
+ `V ${secondCurveStartY}`,
478
+ `Q ${secondCurveBezierX} ${toBlock}, ${secondCurveEndX} ${secondCurveEndY}`,
479
+ `H ${toInline}`,
480
+ ].join(' ');
481
+ return path(d, options.path);
482
+ };
483
+
484
+ const createBracketElementPart = (config) => ({
485
+ dimensions: {
486
+ width: 0,
487
+ height: config.elementPartHeight,
488
+ top: 0,
489
+ left: 0,
490
+ },
491
+ });
492
+
493
+ const createBracketElement = (config) => {
494
+ const { type, area, elementHeight, partHeights } = config;
495
+ const parts = [];
496
+ const newElementBase = {
497
+ dimensions: {
498
+ width: 0,
499
+ height: elementHeight,
500
+ top: 0,
501
+ left: 0,
502
+ },
503
+ containerDimensions: {
504
+ width: 0,
505
+ height: 0,
506
+ top: 0,
507
+ left: 0,
508
+ },
509
+ parts,
510
+ area,
511
+ };
512
+ const newElement = (() => {
513
+ switch (type) {
514
+ case 'header':
515
+ return {
516
+ ...newElementBase,
517
+ type,
518
+ component: config.component,
519
+ round: config.round,
520
+ roundSwissGroup: config.roundSwissGroup,
521
+ };
522
+ case 'match':
523
+ return {
524
+ ...newElementBase,
525
+ type,
526
+ component: config.component,
527
+ match: config.match,
528
+ round: config.round,
529
+ roundSwissGroup: config.roundSwissGroup,
530
+ };
531
+ case 'matchGap':
532
+ case 'roundHeaderGap':
533
+ case 'roundGap':
534
+ case 'colGap':
535
+ return {
536
+ ...newElementBase,
537
+ type,
538
+ };
539
+ case 'continue':
540
+ return {
541
+ ...newElementBase,
542
+ type,
543
+ component: config.component,
544
+ matches: config.matches,
545
+ };
546
+ default:
547
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, `Unknown element type: ${type}`);
548
+ }
549
+ })();
550
+ for (const partHeight of partHeights) {
551
+ parts.push(createBracketElementPart({ elementPartHeight: partHeight }));
552
+ }
553
+ return newElement;
554
+ };
555
+
556
+ const finalizeBracketGrid = (grid) => {
557
+ const finalizedColumns = [];
558
+ const finalizedElementMap = new BracketMap();
559
+ const ignoredSections = [];
560
+ for (const [masterColumnIndex, masterColumn] of grid.grid.masterColumns.entries()) {
561
+ for (const [sectionIndex, section] of masterColumn.sections.entries()) {
562
+ if (ignoredSections.some((s) => s.masterColumnIndex === masterColumnIndex && s.sectionIndex === sectionIndex)) {
563
+ continue;
564
+ }
565
+ const elements = [];
566
+ const firstSubColumn = section.subColumns[0];
567
+ const lastSubColumn = section.subColumns[section.subColumns.length - 1];
568
+ if (!firstSubColumn || !lastSubColumn)
569
+ continue;
570
+ let sectionWidth = section.dimensions.width;
571
+ for (const element of firstSubColumn.elements) {
572
+ if (element.type === 'header') {
573
+ elements.push({
574
+ type: 'header',
575
+ dimensions: element.dimensions,
576
+ component: element.component,
577
+ round: element.round,
578
+ roundSwissGroup: element.roundSwissGroup,
579
+ });
580
+ }
581
+ else if (element.type === 'match') {
582
+ const matchEl = {
583
+ type: 'match',
584
+ dimensions: element.dimensions,
585
+ component: element.component,
586
+ match: element.match,
587
+ round: element.round,
588
+ classes: [element.match.home?.shortId, element.match.away?.shortId].filter((v) => !!v).join(' '),
589
+ roundSwissGroup: element.roundSwissGroup,
590
+ };
591
+ elements.push(matchEl);
592
+ finalizedElementMap.set(element.match.id, matchEl);
593
+ }
594
+ else if (element.type === 'continue') {
595
+ elements.push({
596
+ type: 'continue',
597
+ dimensions: element.dimensions,
598
+ component: element.component,
599
+ matches: element.matches,
600
+ });
601
+ }
602
+ }
603
+ if (!lastSubColumn.span.isEnd) {
604
+ for (let index = masterColumnIndex + 1; index < grid.grid.masterColumns.length; index++) {
605
+ const nextMaster = grid.grid.masterColumns[index];
606
+ const nextSection = nextMaster?.sections[sectionIndex];
607
+ if (!nextSection)
608
+ break;
609
+ sectionWidth += nextSection.dimensions.width;
610
+ ignoredSections.push({ masterColumnIndex: index, sectionIndex });
611
+ if (nextSection.subColumns.some((sc) => sc.span.isEnd)) {
612
+ break;
613
+ }
614
+ }
615
+ }
616
+ if (!elements.length)
617
+ continue;
618
+ finalizedColumns.push({
619
+ dimensions: {
620
+ ...section.dimensions,
621
+ width: sectionWidth,
622
+ },
623
+ elements,
624
+ });
625
+ }
626
+ }
627
+ return {
628
+ columns: finalizedColumns,
629
+ elementMap: finalizedElementMap,
630
+ };
631
+ };
632
+
633
+ const createBracketGrid = (config) => {
634
+ const masterColumns = [];
635
+ const contentWidths = new WeakMap();
636
+ const newGrid = {
637
+ dimensions: { width: 0, height: 0, top: 0, left: 0 },
638
+ masterColumns,
639
+ };
640
+ const pushMasterColumn = (...newMasterColumns) => {
641
+ masterColumns.push(...newMasterColumns);
642
+ };
643
+ const calculateDimensions = () => {
644
+ let currentMasterColumnLeft = 0;
645
+ let maxGridHeight = 0;
646
+ const masterCols = newGrid.masterColumns;
647
+ for (const masterColumn of masterCols) {
648
+ const { padding } = masterColumn;
649
+ masterColumn.dimensions.left = currentMasterColumnLeft;
650
+ masterColumn.dimensions.top = 0;
651
+ const contentWidth = contentWidths.get(masterColumn) ?? masterColumn.dimensions.width;
652
+ contentWidths.set(masterColumn, contentWidth);
653
+ masterColumn.dimensions.width = contentWidth + padding.left + padding.right;
654
+ const sections = masterColumn.sections;
655
+ let runningTop = 0;
656
+ const firstElement = sections[0]?.subColumns[0]?.elements[0];
657
+ const firstSectionIsHeader = firstElement?.type === 'header';
658
+ for (const [secIdx, section] of sections.entries()) {
659
+ const sectionPadding = section.padding ?? padding;
660
+ let sectionPaddingTop;
661
+ if (secIdx === 0) {
662
+ sectionPaddingTop = firstSectionIsHeader ? 0 : sectionPadding.top;
663
+ }
664
+ else {
665
+ sectionPaddingTop = sectionPadding.top;
666
+ }
667
+ section.dimensions.width = masterColumn.dimensions.width;
668
+ section.dimensions.left = masterColumn.dimensions.left;
669
+ section.dimensions.top = runningTop;
670
+ runningTop += sectionPaddingTop;
671
+ const sectionContentWidth = masterColumn.dimensions.width - sectionPadding.left - sectionPadding.right;
672
+ const subColumns = section.subColumns;
673
+ const totalSubColumns = subColumns.length;
674
+ const subColumnWidth = sectionContentWidth / totalSubColumns;
675
+ let currentSubColumnLeft = masterColumn.dimensions.left + sectionPadding.left;
676
+ let maxSectionHeight = 0;
677
+ for (const subColumn of subColumns) {
678
+ subColumn.dimensions.width = subColumnWidth;
679
+ subColumn.dimensions.left = currentSubColumnLeft;
680
+ subColumn.dimensions.top = section.dimensions.top + sectionPaddingTop;
681
+ let totalSubColumnHeight = 0;
682
+ const elements = subColumn.elements;
683
+ for (const element of elements) {
684
+ let totalElementHeight = 0;
685
+ const parts = element.parts;
686
+ for (const part of parts) {
687
+ part.dimensions.width = subColumnWidth;
688
+ part.dimensions.left = currentSubColumnLeft;
689
+ part.dimensions.top = subColumn.dimensions.top + totalSubColumnHeight + totalElementHeight;
690
+ totalElementHeight += part.dimensions.height;
691
+ }
692
+ element.containerDimensions.height = totalElementHeight;
693
+ element.containerDimensions.width = subColumnWidth;
694
+ element.containerDimensions.left = currentSubColumnLeft;
695
+ element.containerDimensions.top = subColumn.dimensions.top + totalSubColumnHeight;
696
+ element.dimensions.width = subColumnWidth;
697
+ element.dimensions.left = currentSubColumnLeft;
698
+ element.dimensions.top =
699
+ element.containerDimensions.top + (totalElementHeight - element.dimensions.height) * 0.5;
700
+ totalSubColumnHeight += totalElementHeight;
701
+ }
702
+ subColumn.dimensions.height = totalSubColumnHeight;
703
+ if (totalSubColumnHeight > maxSectionHeight)
704
+ maxSectionHeight = totalSubColumnHeight;
705
+ currentSubColumnLeft += subColumnWidth;
706
+ }
707
+ // Never negative: a section may start above where it would stack (a folded third place asks for
708
+ // that with a negative padding), but its own box still has to be a box.
709
+ section.dimensions.height = Math.max(0, sectionPaddingTop + maxSectionHeight + sectionPadding.bottom);
710
+ runningTop += maxSectionHeight + sectionPadding.bottom;
711
+ }
712
+ masterColumn.dimensions.height = runningTop;
713
+ if (masterColumn.dimensions.height > maxGridHeight)
714
+ maxGridHeight = masterColumn.dimensions.height;
715
+ currentMasterColumnLeft += masterColumn.dimensions.width;
716
+ }
717
+ newGrid.dimensions.width = currentMasterColumnLeft;
718
+ newGrid.dimensions.height = maxGridHeight;
719
+ calculateSpanningElementDimensions();
720
+ };
721
+ const calculateSpanningElementDimensions = () => {
722
+ const spanDimensions = new Map();
723
+ const masterCols = newGrid.masterColumns;
724
+ for (const [mcIdx, masterColumn] of masterCols.entries()) {
725
+ const sections = masterColumn.sections;
726
+ for (const [secIdx, section] of sections.entries()) {
727
+ const subColumns = section.subColumns;
728
+ for (const [scIdx, subColumn] of subColumns.entries()) {
729
+ const elements = subColumn.elements;
730
+ for (const element of elements) {
731
+ if (!element.span)
732
+ continue;
733
+ const span = element.span;
734
+ const isStartPosition = mcIdx === span.masterColumnStart && secIdx === span.sectionStart && scIdx === span.subColumnStart;
735
+ const spanKey = `${span.masterColumnStart}-${span.masterColumnEnd}-${span.sectionStart}-${span.sectionEnd}-${span.subColumnStart}-${span.subColumnEnd}`;
736
+ if (isStartPosition && !spanDimensions.has(spanKey)) {
737
+ const { left: spanStartLeft, width: totalSpannedWidth } = calculateSpanExtent(span, masterCols);
738
+ const width = config.spanElementWidth;
739
+ // Winner bracket rounds span two lower bracket columns. Left-align them within that
740
+ // span (factor 0) instead of centering (factor 0.5) so each winner round n sits above
741
+ // the lower bracket round its losers drop into (round 2n-2) - the round whose matches
742
+ // merge into the next one. That makes the participant's drop target unambiguous. Every
743
+ // other spanning round (e.g. lower rounds split across sub-columns) stays centered.
744
+ const round = element.type === 'header' || element.type === 'match' ? element.round : null;
745
+ const alignFactor = round?.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.UPPER_BRACKET ? 0 : 0.5;
746
+ spanDimensions.set(spanKey, { width, left: spanStartLeft + (totalSpannedWidth - width) * alignFactor });
747
+ }
748
+ const storedDimensions = spanDimensions.get(spanKey);
749
+ if (storedDimensions) {
750
+ element.dimensions.width = storedDimensions.width;
751
+ element.dimensions.left = storedDimensions.left;
752
+ }
753
+ }
754
+ }
755
+ }
756
+ }
757
+ };
758
+ const calculateSpanExtent = (span, masterColumns) => {
759
+ const start = masterColumns[span.masterColumnStart]?.sections[span.sectionStart]?.subColumns[span.subColumnStart];
760
+ const end = masterColumns[span.masterColumnEnd]?.sections[span.sectionEnd]?.subColumns[span.subColumnEnd];
761
+ if (!start || !end)
762
+ return { left: start?.dimensions.left ?? 0, width: 0 };
763
+ return { left: start.dimensions.left, width: end.dimensions.left + end.dimensions.width - start.dimensions.left };
764
+ };
765
+ const setupElementSpans = () => {
766
+ const masterCols = newGrid.masterColumns;
767
+ for (const [mcIdx, masterColumn] of masterCols.entries()) {
768
+ const sections = masterColumn.sections;
769
+ for (const [secIdx, section] of sections.entries()) {
770
+ const subColumns = section.subColumns;
771
+ for (const [scIdx, subColumn] of subColumns.entries()) {
772
+ if (subColumn.span.isStart && subColumn.span.isEnd)
773
+ continue;
774
+ let spanStart = { masterColumnIndex: mcIdx, sectionIndex: secIdx, subColumnIndex: scIdx };
775
+ if (!subColumn.span.isStart) {
776
+ outer: for (let m = mcIdx; m >= 0; m--) {
777
+ const mc = masterCols[m];
778
+ if (!mc)
779
+ continue;
780
+ const sec = mc.sections[secIdx];
781
+ if (!sec)
782
+ continue;
783
+ const end = m === mcIdx ? scIdx : sec.subColumns.length - 1;
784
+ for (let s = end; s >= 0; s--) {
785
+ if (sec.subColumns[s]?.span.isStart) {
786
+ spanStart = { masterColumnIndex: m, sectionIndex: secIdx, subColumnIndex: s };
787
+ break outer;
788
+ }
789
+ }
790
+ }
791
+ }
792
+ let spanEnd = { masterColumnIndex: mcIdx, sectionIndex: secIdx, subColumnIndex: scIdx };
793
+ if (!subColumn.span.isEnd) {
794
+ outer: for (let m = mcIdx; m < masterCols.length; m++) {
795
+ const mc = masterCols[m];
796
+ if (!mc)
797
+ continue;
798
+ const sec = mc.sections[secIdx];
799
+ if (!sec)
800
+ continue;
801
+ const start = m === mcIdx ? scIdx : 0;
802
+ for (let s = start; s < sec.subColumns.length; s++) {
803
+ if (sec.subColumns[s]?.span.isEnd) {
804
+ spanEnd = { masterColumnIndex: m, sectionIndex: secIdx, subColumnIndex: s };
805
+ break outer;
806
+ }
807
+ }
808
+ }
809
+ }
810
+ const elements = subColumn.elements;
811
+ for (const element of elements) {
812
+ element.span = {
813
+ masterColumnStart: spanStart.masterColumnIndex,
814
+ masterColumnEnd: spanEnd.masterColumnIndex,
815
+ sectionStart: spanStart.sectionIndex,
816
+ sectionEnd: spanEnd.sectionIndex,
817
+ subColumnStart: spanStart.subColumnIndex,
818
+ subColumnEnd: spanEnd.subColumnIndex,
819
+ };
820
+ }
821
+ }
822
+ }
823
+ }
824
+ };
825
+ return {
826
+ grid: newGrid,
827
+ pushMasterColumn,
828
+ calculateDimensions,
829
+ setupElementSpans,
830
+ };
831
+ };
832
+
833
+ const createBracketMasterColumn = (config) => {
834
+ const { columnWidth, padding } = config;
835
+ const sections = [];
836
+ const newMasterColumn = {
837
+ dimensions: {
838
+ width: columnWidth,
839
+ height: 0,
840
+ top: 0,
841
+ left: 0,
842
+ },
843
+ padding,
844
+ sections,
845
+ };
846
+ const pushSection = (...newSections) => {
847
+ sections.push(...newSections);
848
+ };
849
+ return {
850
+ masterColumn: newMasterColumn,
851
+ pushSection,
852
+ };
853
+ };
854
+
855
+ const createBracketMasterColumnSection = (config) => {
856
+ const { type, padding } = config;
857
+ const subColumns = [];
858
+ const newMasterColumnSection = {
859
+ dimensions: {
860
+ width: 0,
861
+ height: 0,
862
+ top: 0,
863
+ left: 0,
864
+ },
865
+ subColumns,
866
+ type,
867
+ padding,
868
+ };
869
+ const pushSubColumn = (...newSubColumns) => {
870
+ subColumns.push(...newSubColumns);
871
+ };
872
+ return {
873
+ masterColumnSection: newMasterColumnSection,
874
+ pushSubColumn,
875
+ };
876
+ };
877
+
878
+ const createBracketSubColumn = (config) => {
879
+ const { span } = config;
880
+ const elements = [];
881
+ const newSubColumn = {
882
+ dimensions: {
883
+ width: 0,
884
+ height: 0,
885
+ top: 0,
886
+ left: 0,
887
+ },
888
+ elements,
889
+ span,
890
+ };
891
+ const pushElement = (...newElements) => {
892
+ elements.push(...newElements);
893
+ };
894
+ return {
895
+ subColumn: newSubColumn,
896
+ pushElement,
897
+ };
898
+ };
899
+
900
+ const isBracketContinueMatch = (match) => match.round.type !== COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE &&
901
+ (match.relation.type === 'one-to-nothing' || match.relation.type === 'two-to-nothing');
902
+ const getBracketContinueMatches = (bracketData) => Array.from(bracketData.matches.values()).filter((match) => isBracketContinueMatch(match));
903
+ const createBracketContinueMasterColumn = (config) => {
904
+ const { existingMasterColumns, columnWidth, elementHeight, headerOffset, component, matches } = config;
905
+ const lastMasterColumn = existingMasterColumns[existingMasterColumns.length - 1];
906
+ if (!lastMasterColumn) {
907
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'No last master column found in existing master columns');
908
+ }
909
+ // Dimensions are not calculated yet at this point, but all part heights are static, so the
910
+ // total column height is the sum of the tallest sub column of every section plus its padding - the
911
+ // same sum `calculateDimensions` makes, which skips the top padding of a leading header section.
912
+ const firstSectionIsHeader = lastMasterColumn.sections[0]?.subColumns[0]?.elements[0]?.type === 'header';
913
+ let totalHeight = 0;
914
+ for (const [sectionIndex, section] of lastMasterColumn.sections.entries()) {
915
+ const padding = section.padding ?? lastMasterColumn.padding;
916
+ const paddingTop = sectionIndex === 0 && firstSectionIsHeader ? 0 : padding.top;
917
+ const sectionHeight = Math.max(0, ...section.subColumns.map((subColumn) => subColumn.elements.reduce((subColumnHeight, element) => subColumnHeight + element.parts.reduce((sum, part) => sum + part.dimensions.height, 0), 0)));
918
+ totalHeight += paddingTop + sectionHeight + padding.bottom;
919
+ }
920
+ const { masterColumn, pushSection } = createBracketMasterColumn({
921
+ columnWidth,
922
+ padding: {
923
+ bottom: 0,
924
+ left: 0,
925
+ right: 0,
926
+ top: 0,
927
+ },
928
+ });
929
+ const { masterColumnSection, pushSubColumn } = createBracketMasterColumnSection({
930
+ type: 'round',
931
+ });
932
+ const { subColumn, pushElement } = createBracketSubColumn({
933
+ span: {
934
+ isStart: true,
935
+ isEnd: true,
936
+ },
937
+ });
938
+ if (headerOffset > 0) {
939
+ const headerGapElement = createBracketElement({
940
+ area: '.',
941
+ type: 'colGap',
942
+ elementHeight: headerOffset,
943
+ partHeights: [headerOffset],
944
+ });
945
+ pushElement(headerGapElement);
946
+ }
947
+ const containerHeight = Math.max(elementHeight, totalHeight - headerOffset);
948
+ const continueElement = createBracketElement({
949
+ area: 'continue',
950
+ type: 'continue',
951
+ elementHeight,
952
+ partHeights: [containerHeight],
953
+ component,
954
+ matches,
955
+ });
956
+ pushElement(continueElement);
957
+ pushSubColumn(subColumn);
958
+ pushSection(masterColumnSection);
959
+ return masterColumn;
960
+ };
961
+
962
+ // eslint-disable-next-line max-params -- geometry helper: (from, to, options) reads naturally positional
963
+ const linePath = (from, to, options) => {
964
+ const inverted = options.inverted ?? false;
965
+ const fromInline = inverted ? from.inline.start : from.inline.end;
966
+ const toInline = inverted ? to.inline.end : to.inline.start;
967
+ return path(`M ${fromInline} ${from.block.center} L ${toInline} ${to.block.center}`, options.path);
968
+ };
969
+ /**
970
+ * The connector between two cards sharing a column, where {@link linePath} and `curvePath` both
971
+ * degenerate to a zero-width horizontal run: straight down (or up) the column, edge to edge.
972
+ */
973
+ // eslint-disable-next-line max-params -- geometry helper: (from, to, options) reads naturally positional
974
+ const verticalPath = (from, to, options) => {
975
+ const inline = (from.inline.center + to.inline.center) / 2;
976
+ const isDownwards = to.block.center >= from.block.center;
977
+ const fromBlock = isDownwards ? from.block.end : from.block.start;
978
+ const toBlock = isDownwards ? to.block.start : to.block.end;
979
+ return path(`M ${inline} ${fromBlock} V ${toBlock}`, options.path);
980
+ };
981
+ /**
982
+ * A same-column connector for two cards with others stacked between them: out sideways, along the
983
+ * empty strip beside the column, and back in. {@link verticalPath} would run through everything in
984
+ * its way.
985
+ */
986
+ // eslint-disable-next-line max-params -- geometry helper: (from, to, options) reads naturally positional
987
+ const gutterPath = (from, to, options) => {
988
+ const inverted = options.inverted ?? false;
989
+ const fromInline = inverted ? from.inline.start : from.inline.end;
990
+ const toInline = inverted ? to.inline.start : to.inline.end;
991
+ const runInline = inverted
992
+ ? Math.min(fromInline, toInline) - options.gutter
993
+ : Math.max(fromInline, toInline) + options.gutter;
994
+ return path(`M ${fromInline} ${from.block.center} H ${runInline} V ${to.block.center} H ${toInline}`, options.path);
995
+ };
996
+
997
+ const makePos = (dimensions) => ({
998
+ block: {
999
+ start: dimensions.top,
1000
+ end: dimensions.top + dimensions.height,
1001
+ center: dimensions.top + dimensions.height / 2,
1002
+ },
1003
+ inline: {
1004
+ start: dimensions.left,
1005
+ end: dimensions.left + dimensions.width,
1006
+ center: dimensions.left + dimensions.width / 2,
1007
+ },
1008
+ });
1009
+ /**
1010
+ * Two cards in the same column - the shape a stacked layout's centre chain makes, where a round hangs
1011
+ * directly below the one that feeds it and every horizontal run has zero width.
1012
+ */
1013
+ const isSameColumn = (from, to) => Math.abs(from.inline.center - to.inline.center) < 1;
1014
+ /** A one-to-one connector, whichever way round the two cards sit. */
1015
+ // eslint-disable-next-line max-params -- geometry helper: (from, to, options) reads naturally positional
1016
+ const straightPath = (from, to, options) => isSameColumn(from, to) ? verticalPath(from, to, options) : linePath(from, to, options);
1017
+ /** One arm of a merge, collapsing to a straight run when the two cards share a column. */
1018
+ const mergePath = (from, to, direction, options) => isSameColumn(from, to) ? verticalPath(from, to, { path: options.path }) : curvePath(from, to, direction, options);
1019
+ const edgeId = (fromMatchId, toMatchId) => `${fromMatchId}|${toMatchId}`;
1020
+ const drawMan = (dimensions) => {
1021
+ const edges = [];
1022
+ const takenIds = new Set();
1023
+ // A repeated id would make the host's `@for` throw, and a mirrored fold can draw the same pair of
1024
+ // cards from both of its sides.
1025
+ const pushEdge = (edge) => {
1026
+ let id = edge.id;
1027
+ for (let attempt = 2; takenIds.has(id); attempt++)
1028
+ id = `${edge.id}#${attempt}`;
1029
+ takenIds.add(id);
1030
+ edges.push(id === edge.id ? edge : { ...edge, id });
1031
+ };
1032
+ const continueElement = dimensions.bracketGrid.columns
1033
+ .flatMap((col) => col.elements)
1034
+ .find((el) => el.type === 'continue');
1035
+ const continuePos = continueElement ? makePos(continueElement.dimensions) : null;
1036
+ const continueSources = [];
1037
+ for (const col of dimensions.bracketGrid.columns) {
1038
+ for (const el of col.elements) {
1039
+ if (el.type !== 'match')
1040
+ continue;
1041
+ const currentMatchParticipantsShortIds = [el.match.home?.shortId, el.match.away?.shortId]
1042
+ .filter((id) => !!id)
1043
+ .join(' ');
1044
+ const currentPos = makePos(el.dimensions);
1045
+ if (el.round.type === COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE)
1046
+ continue;
1047
+ if (continuePos && isBracketContinueMatch(el.match)) {
1048
+ continueSources.push({
1049
+ pos: currentPos,
1050
+ className: el.match.winner?.shortId || '',
1051
+ matchId: el.match.id,
1052
+ });
1053
+ }
1054
+ // Crossing the middle of a fold. Every other line here is drawn by the match it flows *into*, but a
1055
+ // round in the middle of a mirrored bracket is fed from both sides and its relation names only one
1056
+ // of them - so the round on the way back draws its own line. Deliberately outside the switch below:
1057
+ // which relation a match has says nothing about which side of a fold it sits on.
1058
+ if (el.round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT && 'nextMatch' in el.match.relation) {
1059
+ const { nextMatch, nextRound } = el.match.relation;
1060
+ if (nextRound.mirrorRoundType === null) {
1061
+ const next = dimensions.bracketGrid.matchElementMap.getOrThrow(nextMatch.id);
1062
+ // The winner's short id, not both participants': this line is somebody advancing along it, and
1063
+ // the journey highlight lights the path a participant actually travelled.
1064
+ pushEdge(straightPath(makePos(next.dimensions), currentPos, {
1065
+ path: {
1066
+ ...dimensions.path,
1067
+ id: edgeId(nextMatch.id, el.match.id),
1068
+ className: el.match.winner?.shortId || '',
1069
+ },
1070
+ }));
1071
+ }
1072
+ }
1073
+ switch (el.match.relation.type) {
1074
+ case 'none':
1075
+ case 'nothing-to-one': {
1076
+ continue;
1077
+ }
1078
+ case 'one-to-nothing':
1079
+ case 'one-to-one': {
1080
+ const prev = dimensions.bracketGrid.matchElementMap.getOrThrow(el.match.relation.previousMatch.id);
1081
+ const prevPos = makePos(prev.dimensions);
1082
+ // The winner of the match it comes from, like every other connector, or the journey highlight
1083
+ // lights the line for whoever arrived from somewhere else.
1084
+ pushEdge(straightPath(prevPos, currentPos, {
1085
+ path: {
1086
+ ...dimensions.path,
1087
+ id: edgeId(el.match.relation.previousMatch.id, el.match.id),
1088
+ className: el.match.relation.previousMatch.winner?.shortId || '',
1089
+ },
1090
+ // The way back runs right to left: the round it comes from sits on the *other* side.
1091
+ inverted: el.round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT,
1092
+ }));
1093
+ break;
1094
+ }
1095
+ case 'two-to-nothing':
1096
+ case 'two-to-one': {
1097
+ const prevUpper = dimensions.bracketGrid.matchElementMap.getOrThrow(el.match.relation.previousUpperMatch.id);
1098
+ const prevLower = dimensions.bracketGrid.matchElementMap.getOrThrow(el.match.relation.previousLowerMatch.id);
1099
+ const prevUpperPos = makePos(prevUpper.dimensions);
1100
+ const prevLowerPos = makePos(prevLower.dimensions);
1101
+ const upperId = edgeId(el.match.relation.previousUpperMatch.id, el.match.id);
1102
+ const lowerId = edgeId(el.match.relation.previousLowerMatch.id, el.match.id);
1103
+ const isLowerUpperMerger = el.match.relation.previousLowerRound.id !== el.match.relation.previousUpperRound.id;
1104
+ const invertCurve = el.round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT;
1105
+ const curveOptions = {
1106
+ ...dimensions.curve,
1107
+ inverted: invertCurve,
1108
+ path: { ...dimensions.path, id: upperId, className: '' },
1109
+ };
1110
+ if (isLowerUpperMerger) {
1111
+ pushEdge(straightPath(prevUpperPos, currentPos, {
1112
+ path: { ...dimensions.path, id: upperId, className: currentMatchParticipantsShortIds },
1113
+ inverted: invertCurve,
1114
+ }));
1115
+ }
1116
+ else {
1117
+ pushEdge(mergePath(prevUpperPos, currentPos, 'down', {
1118
+ ...curveOptions,
1119
+ path: {
1120
+ ...curveOptions.path,
1121
+ className: el.match.relation.previousUpperMatch.winner?.shortId || '',
1122
+ },
1123
+ }));
1124
+ }
1125
+ const lowerPathOptions = {
1126
+ ...curveOptions.path,
1127
+ id: lowerId,
1128
+ className: el.match.relation.previousLowerMatch.winner?.shortId || '',
1129
+ };
1130
+ // A feeder directly *below* in the same column is the one connector a stacked layout has that
1131
+ // crosses its own cards - the losers champion coming up to the grand final, past everything
1132
+ // hanging under it. Every other same-column pair is adjacent, so a straight run is clear.
1133
+ const isBlockToBlock = isSameColumn(prevLowerPos, currentPos) && prevLowerPos.block.start > currentPos.block.end;
1134
+ pushEdge(isBlockToBlock
1135
+ ? gutterPath(prevLowerPos, currentPos, {
1136
+ // Dashed, like the continue column's: it runs the height of a block past cards it has
1137
+ // nothing to do with, and reads as a stray bracket edge if it looks like one.
1138
+ path: {
1139
+ ...(dimensions.continuePath ?? dimensions.path),
1140
+ id: lowerId,
1141
+ className: lowerPathOptions.className,
1142
+ },
1143
+ inverted: invertCurve,
1144
+ gutter: dimensions.columnGap / 2,
1145
+ })
1146
+ : mergePath(prevLowerPos, currentPos, 'up', { ...curveOptions, path: lowerPathOptions }));
1147
+ break;
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ if (continuePos && continueSources.length) {
1153
+ const continuePathOptions = dimensions.continuePath ?? dimensions.path;
1154
+ const curveAmount = dimensions.curve.lineStartingCurveAmount;
1155
+ const trunkInline = continuePos.inline.start - dimensions.columnGap / 2;
1156
+ const continueBlockCenter = continuePos.block.center;
1157
+ const sourceBlocks = continueSources.map((source) => source.pos.block.center);
1158
+ const firstSourceBlock = Math.min(...sourceBlocks);
1159
+ const lastSourceBlock = Math.max(...sourceBlocks);
1160
+ const trunkBlocks = [continueBlockCenter];
1161
+ for (const source of continueSources) {
1162
+ const sourceBlockCenter = source.pos.block.center;
1163
+ const blockDistance = continueBlockCenter - sourceBlockCenter;
1164
+ const sourcePathOptions = {
1165
+ ...continuePathOptions,
1166
+ id: edgeId(source.matchId, 'continue'),
1167
+ className: source.className,
1168
+ };
1169
+ const isTrunkCorner = (sourceBlockCenter === firstSourceBlock && blockDistance > 0.5) ||
1170
+ (sourceBlockCenter === lastSourceBlock && blockDistance < -0.5);
1171
+ // The straight run into the trunk is the same three commands with a zero-radius corner, so that a
1172
+ // source turning into a corner as the rows move animates rather than jumping.
1173
+ const curve = isTrunkCorner ? Math.min(curveAmount, Math.abs(blockDistance)) : 0;
1174
+ const curveEndBlock = sourceBlockCenter + curve * Math.sign(blockDistance);
1175
+ pushEdge(path(`M ${source.pos.inline.end} ${sourceBlockCenter}
1176
+ H ${trunkInline - curve}
1177
+ Q ${trunkInline} ${sourceBlockCenter}, ${trunkInline} ${curveEndBlock}`, sourcePathOptions));
1178
+ trunkBlocks.push(curveEndBlock);
1179
+ }
1180
+ const trunkBlockStart = Math.min(...trunkBlocks);
1181
+ const trunkBlockEnd = Math.max(...trunkBlocks);
1182
+ if (trunkBlockEnd - trunkBlockStart > 0.5) {
1183
+ pushEdge(path(`M ${trunkInline} ${trunkBlockStart} V ${trunkBlockEnd}`, {
1184
+ ...continuePathOptions,
1185
+ id: 'continue|trunk',
1186
+ className: '',
1187
+ }));
1188
+ }
1189
+ pushEdge(path(`M ${trunkInline} ${continueBlockCenter} H ${continuePos.inline.start}`, {
1190
+ ...continuePathOptions,
1191
+ id: 'continue|element',
1192
+ className: '',
1193
+ }));
1194
+ }
1195
+ return { edges, rects: [], gradients: [] };
1196
+ };
1197
+
1198
+ const BRACKET_SWISS_GROUP_COLOR_TYPE = {
1199
+ /** The starting group (0-0) */
1200
+ NEUTRAL: 'neutral',
1201
+ /** The group has more wins than losses */
1202
+ POSITIVE: 'positive',
1203
+ /** The group is one loss away from elimination */
1204
+ NEGATIVE: 'negative',
1205
+ /** Everything in between */
1206
+ WARNING: 'warning',
1207
+ };
1208
+ const getSwissGroupColorType = (wins, losses) => {
1209
+ if (wins === 0 && losses === 0)
1210
+ return BRACKET_SWISS_GROUP_COLOR_TYPE.NEUTRAL;
1211
+ if (losses === SWISS_ELIMINATE_LOSSES - 1)
1212
+ return BRACKET_SWISS_GROUP_COLOR_TYPE.NEGATIVE;
1213
+ if (wins > losses)
1214
+ return BRACKET_SWISS_GROUP_COLOR_TYPE.POSITIVE;
1215
+ return BRACKET_SWISS_GROUP_COLOR_TYPE.WARNING;
1216
+ };
1217
+ const factorialCache = /* @__PURE__ */ new Map();
1218
+ const getAvailableSwissGroupsForRound = (roundNumber, totalMatchesInRound) => {
1219
+ const advanceWins = SWISS_ADVANCE_WINS;
1220
+ const eliminateLosses = SWISS_ELIMINATE_LOSSES;
1221
+ const getFactorial = (n) => {
1222
+ if (n <= 1)
1223
+ return 1;
1224
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1225
+ if (factorialCache.has(n))
1226
+ return factorialCache.get(n);
1227
+ const result = n * getFactorial(n - 1);
1228
+ factorialCache.set(n, result);
1229
+ return result;
1230
+ };
1231
+ const roundFactorial = getFactorial(roundNumber);
1232
+ let totalCombinations = 0;
1233
+ const validGroups = [];
1234
+ for (let wins = roundNumber; wins >= 0; wins--) {
1235
+ const losses = roundNumber - wins;
1236
+ const remainingGames = advanceWins + eliminateLosses - (wins + losses) - 1;
1237
+ const notYetEliminated = losses < eliminateLosses;
1238
+ const canStillAdvance = wins < advanceWins && remainingGames >= 0;
1239
+ if (!canStillAdvance || !notYetEliminated)
1240
+ continue;
1241
+ const combinations = roundFactorial / (getFactorial(wins) * getFactorial(losses));
1242
+ totalCombinations += combinations;
1243
+ validGroups.push({ wins, losses, combinations });
1244
+ }
1245
+ return validGroups.map(({ wins, losses, combinations }) => ({
1246
+ id: `${wins}-${losses}`,
1247
+ name: `${wins}-${losses}`,
1248
+ matchesInGroup: Math.round((combinations / totalCombinations) * totalMatchesInRound),
1249
+ }));
1250
+ };
1251
+ /** The group an undrawn match falls into when its round has no win/loss record to group by. */
1252
+ const UNDRAWN_SWISS_GROUP_ID = 'undrawn';
1253
+ const getRecordBeforeMatch = (participant) => ({
1254
+ wins: participant.winCount - (participant.result === 'win' ? 1 : 0),
1255
+ losses: participant.lossCount - (participant.result === 'loss' ? 1 : 0),
1256
+ });
1257
+ const generateBracketRoundSwissGroupMaps = (bracketData) => {
1258
+ if (bracketData.mode !== TOURNAMENT_MODE.SWISS_WITH_ELIMINATION) {
1259
+ return null;
1260
+ }
1261
+ const roundsWithSwissGroups = new Map();
1262
+ let roundNumber = 0;
1263
+ for (const bracketRound of bracketData.rounds.values()) {
1264
+ const availableGroups = getAvailableSwissGroupsForRound(roundNumber, bracketRound.matchCount);
1265
+ const roundSwissData = {
1266
+ groups: new Map(),
1267
+ };
1268
+ for (const group of availableGroups) {
1269
+ const subGroup = {
1270
+ id: group.id,
1271
+ name: group.name,
1272
+ matches: new BracketMap(),
1273
+ allowedMatchCount: group.matchesInGroup,
1274
+ };
1275
+ roundSwissData.groups.set(group.id, subGroup);
1276
+ }
1277
+ const emptyMatchIds = [];
1278
+ for (const match of bracketRound.matches.values()) {
1279
+ const anyParticipant = match.home || match.away;
1280
+ if (!anyParticipant) {
1281
+ emptyMatchIds.push(match.id);
1282
+ continue;
1283
+ }
1284
+ const { wins, losses } = getRecordBeforeMatch(anyParticipant);
1285
+ const groupId = `${wins}-${losses}`;
1286
+ // The table above enumerates the records of somebody who played every round. A bye, a walkover or
1287
+ // an uneven field leaves a participant with fewer games than the round index, so their group is
1288
+ // real but not in the table - create it rather than losing the whole bracket over one match.
1289
+ let group = roundSwissData.groups.get(groupId);
1290
+ if (!group) {
1291
+ group = { id: groupId, name: `${wins}-${losses}`, matches: new BracketMap(), allowedMatchCount: 0 };
1292
+ roundSwissData.groups.set(groupId, group);
1293
+ }
1294
+ group.matches.set(match.id, match);
1295
+ }
1296
+ for (const emptyMatchId of emptyMatchIds) {
1297
+ const match = bracketRound.matches.getOrThrow(emptyMatchId);
1298
+ const groups = [...roundSwissData.groups.values()];
1299
+ let group = groups.find((candidate) => candidate.matches.size < candidate.allowedMatchCount) ??
1300
+ groups.reduce((fewest, candidate) => (!fewest || candidate.matches.size < fewest.matches.size ? candidate : fewest), null);
1301
+ // A round past the last one a 3-3 swiss can reach - the elimination rounds of a
1302
+ // swiss-with-elimination stage - has no record groups, and an undrawn match carries no record to
1303
+ // build one from. A nameless group keeps the round drawn instead of losing the whole bracket.
1304
+ if (!group) {
1305
+ group = { id: UNDRAWN_SWISS_GROUP_ID, name: '', matches: new BracketMap(), allowedMatchCount: 0 };
1306
+ roundSwissData.groups.set(group.id, group);
1307
+ }
1308
+ group.matches.set(match.id, match);
1309
+ }
1310
+ for (const [groupId, group] of roundSwissData.groups) {
1311
+ if (!group.matches.size)
1312
+ roundSwissData.groups.delete(groupId);
1313
+ }
1314
+ // The map is drawn top to bottom in insertion order, so a group created on demand has to be sorted
1315
+ // back into the best-record-first order the table was built in.
1316
+ const orderedGroups = [...roundSwissData.groups.entries()].sort(([a], [b]) => {
1317
+ const [aWins = 0, aLosses = 0] = a.split('-').map(Number);
1318
+ const [bWins = 0, bLosses = 0] = b.split('-').map(Number);
1319
+ return bWins - aWins || aLosses - bLosses;
1320
+ });
1321
+ roundSwissData.groups = new Map(orderedGroups);
1322
+ roundsWithSwissGroups.set(bracketRound.id, roundSwissData);
1323
+ roundNumber++;
1324
+ }
1325
+ return roundsWithSwissGroups;
1326
+ };
1327
+
1328
+ // Every finalized column of a swiss grid is one group section. The connection lines run
1329
+ // from group to group instead of match to match: the winners of group (w-l) move to group
1330
+ // (w+1-l) inside the next round, the losers move to group (w-l+1).
1331
+ const collectSwissRoundGeometries = (bracketGrid) => {
1332
+ const rounds = new Map();
1333
+ for (const column of bracketGrid.columns) {
1334
+ const matchElements = column.elements.filter((element) => element.type === 'match');
1335
+ const group = matchElements[0]?.roundSwissGroup;
1336
+ const round = matchElements[0]?.round;
1337
+ if (!group || !round)
1338
+ continue;
1339
+ const [wins, losses] = group.id.split('-').map(Number);
1340
+ if (wins === undefined || losses === undefined || isNaN(wins) || isNaN(losses))
1341
+ continue;
1342
+ const matchesTop = Math.min(...matchElements.map((element) => element.dimensions.top));
1343
+ const matchesBottom = Math.max(...matchElements.map((element) => element.dimensions.top + element.dimensions.height));
1344
+ const winnerShortIds = [];
1345
+ const loserShortIds = [];
1346
+ for (const element of matchElements) {
1347
+ if (!element.match.winner)
1348
+ continue;
1349
+ const winner = element.match.winner;
1350
+ const loser = element.match.home?.id === winner.id ? element.match.away : element.match.home;
1351
+ winnerShortIds.push(winner.shortId);
1352
+ if (loser)
1353
+ loserShortIds.push(loser.shortId);
1354
+ }
1355
+ // The lines attach to the group border, which wraps the matches plus the group
1356
+ // padding and therefore matches the section bounds horizontally.
1357
+ const position = {
1358
+ block: {
1359
+ start: matchesTop,
1360
+ end: matchesBottom,
1361
+ center: (matchesTop + matchesBottom) / 2,
1362
+ },
1363
+ inline: {
1364
+ start: column.dimensions.left,
1365
+ end: column.dimensions.left + column.dimensions.width,
1366
+ center: column.dimensions.left + column.dimensions.width / 2,
1367
+ },
1368
+ };
1369
+ let roundGeometry = rounds.get(round.id);
1370
+ if (!roundGeometry) {
1371
+ roundGeometry = { groups: new Map() };
1372
+ rounds.set(round.id, roundGeometry);
1373
+ }
1374
+ roundGeometry.groups.set(group.id, {
1375
+ id: group.id,
1376
+ wins,
1377
+ losses,
1378
+ colorType: getSwissGroupColorType(wins, losses),
1379
+ position,
1380
+ winnerShortIds,
1381
+ loserShortIds,
1382
+ });
1383
+ }
1384
+ // The finalized columns are ordered left to right, so first appearance equals round order
1385
+ return Array.from(rounds.values());
1386
+ };
1387
+ const groupBorderRect = (group, border, color, id) => {
1388
+ // The group box wraps the matches plus the group padding plus the border itself. The
1389
+ // rect is inset by half the stroke width so the stroke renders fully inside the group
1390
+ // bounds instead of getting cut off at the edges of the bracket container.
1391
+ const boxPadding = border.padding + border.width;
1392
+ const strokeInset = border.width / 2;
1393
+ return {
1394
+ id,
1395
+ x: group.position.inline.start + strokeInset,
1396
+ y: group.position.block.start - boxPadding + strokeInset,
1397
+ width: group.position.inline.end - group.position.inline.start - border.width,
1398
+ height: group.position.block.end - group.position.block.start + boxPadding * 2 - border.width,
1399
+ radius: border.radius,
1400
+ stroke: color ?? 'currentColor',
1401
+ strokeWidth: border.width,
1402
+ cssClass: `et-bracket-swiss-group-border et-bracket-swiss-group-border--${group.id} et-bracket-swiss-group-border--${group.colorType}`,
1403
+ };
1404
+ };
1405
+ // A horizontal gradient for the connection lines: they leave the source group in its
1406
+ // color, fade to the neutral color towards their vertical jog in the middle and fade into
1407
+ // the target group color on the second half. The connection lines always run from left to
1408
+ // right, so user space coordinates can be used (they also work for straight lines, where
1409
+ // the bounding box has no height).
1410
+ const lineGradient = (config) => ({
1411
+ id: config.id,
1412
+ fromX: config.fromX,
1413
+ toX: config.toX,
1414
+ stops: [
1415
+ { offset: '0%', color: config.from },
1416
+ { offset: '50%', color: config.neutral },
1417
+ { offset: '100%', color: config.to },
1418
+ ],
1419
+ });
1420
+ const drawSwissMan = (dimensions) => {
1421
+ const edges = [];
1422
+ const rects = [];
1423
+ const gradients = [];
1424
+ const roundGeometries = collectSwissRoundGeometries(dimensions.bracketGrid);
1425
+ const colors = dimensions.colors;
1426
+ for (const [roundIndex, roundGeometry] of roundGeometries.entries()) {
1427
+ for (const group of roundGeometry.groups.values()) {
1428
+ rects.push(groupBorderRect(group, dimensions.groupBorder, colors?.[group.colorType], `r${roundIndex}|${group.id}`));
1429
+ }
1430
+ }
1431
+ const curveOptions = {
1432
+ lineStartingCurveAmount: dimensions.curve.lineStartingCurveAmount,
1433
+ lineEndingCurveAmount: dimensions.curve.lineStartingCurveAmount,
1434
+ };
1435
+ for (const [roundIndex, roundGeometry] of roundGeometries.entries()) {
1436
+ const nextRoundGeometry = roundGeometries[roundIndex + 1];
1437
+ if (!nextRoundGeometry)
1438
+ break;
1439
+ for (const group of roundGeometry.groups.values()) {
1440
+ const targets = [
1441
+ { geometry: nextRoundGeometry.groups.get(`${group.wins + 1}-${group.losses}`), shortIds: group.winnerShortIds },
1442
+ { geometry: nextRoundGeometry.groups.get(`${group.wins}-${group.losses + 1}`), shortIds: group.loserShortIds },
1443
+ ];
1444
+ for (const { geometry: target, shortIds } of targets) {
1445
+ if (!target)
1446
+ continue;
1447
+ const edgeId = `r${roundIndex}|${group.id}|${target.id}`;
1448
+ // Only URI characters: this ends up inside a `url(#…)` the `stroke` attribute has to resolve.
1449
+ const gradientId = `${dimensions.idPrefix}-swiss-line-r${roundIndex}-${group.id}-to-${target.id}`;
1450
+ const neutralColor = colors?.neutral;
1451
+ let stroke = neutralColor;
1452
+ if (neutralColor) {
1453
+ const from = colors?.[group.colorType] ?? neutralColor;
1454
+ const to = colors?.[target.colorType] ?? neutralColor;
1455
+ if (from !== neutralColor || to !== neutralColor) {
1456
+ gradients.push(lineGradient({
1457
+ id: gradientId,
1458
+ fromX: group.position.inline.end,
1459
+ toX: target.position.inline.start,
1460
+ from,
1461
+ neutral: neutralColor,
1462
+ to,
1463
+ }));
1464
+ stroke = `url(#${gradientId})`;
1465
+ }
1466
+ }
1467
+ const pathOptions = {
1468
+ ...dimensions.path,
1469
+ id: edgeId,
1470
+ className: shortIds.join(' '),
1471
+ stroke,
1472
+ };
1473
+ const blockDelta = target.position.block.center - group.position.block.center;
1474
+ // Always a curve, even between two groups on the same row, where the bends collapse to nothing
1475
+ // and it draws as a straight run - one shape per connector is what lets it animate.
1476
+ edges.push(curvePath(group.position, target.position, blockDelta < 0 ? 'up' : 'down', {
1477
+ ...curveOptions,
1478
+ path: pathOptions,
1479
+ }));
1480
+ }
1481
+ }
1482
+ }
1483
+ return { edges, rects, gradients };
1484
+ };
1485
+
1486
+ const calculateUpperLowerRatio = (upperRoundsCount, lowerRoundsCount) => {
1487
+ const remainingUpperRounds = upperRoundsCount - 1;
1488
+ const remainingLowerRounds = lowerRoundsCount - 1;
1489
+ if (remainingUpperRounds === 0)
1490
+ return 1;
1491
+ return remainingLowerRounds / remainingUpperRounds;
1492
+ };
1493
+ const calculateColumnSplitFactor = (upperToLowerRatio) => (upperToLowerRatio === 1.5 ? 2 : 1);
1494
+ const calculateLowerRoundIndex = (subColumnIndex, splitFactor) => Math.floor(subColumnIndex / splitFactor);
1495
+ // eslint-disable-next-line max-params -- index math over positional grid coordinates
1496
+ const calculateUpperRoundIndex = (subColumnIndex, upperToLowerRatio, splitFactor) => {
1497
+ const completeColumnIndex = Math.floor(subColumnIndex / splitFactor);
1498
+ if (completeColumnIndex === 0)
1499
+ return 0;
1500
+ return Math.floor((completeColumnIndex - 1) / upperToLowerRatio) + 1;
1501
+ };
1502
+
1503
+ const createRoundBracketSubColumnRelativeToFirstRound = (config) => {
1504
+ const { firstRound, round, options, span, hasReverseFinal } = config;
1505
+ const { subColumn, pushElement } = createBracketSubColumn({
1506
+ span,
1507
+ });
1508
+ const matchFactor = config.matchFactor ?? Math.max(1, (options.rowSpanMatchCount ?? firstRound.matchCount) / round.matchCount);
1509
+ const matches = Array.from(round.matches.values());
1510
+ const isFinalRound = hasReverseFinal
1511
+ ? round.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL
1512
+ : round.type === COMMON_BRACKET_ROUND_TYPE.FINAL;
1513
+ const roundHeaderGap = isFinalRound
1514
+ ? Math.max(options.roundHeaderGap, options.finalRoundHeaderGap ?? 0)
1515
+ : options.roundHeaderGap;
1516
+ const elementsToCreate = [];
1517
+ if (options.roundHeaderHeight > 0) {
1518
+ elementsToCreate.push({
1519
+ type: 'header',
1520
+ area: `h${round.shortId}`,
1521
+ partHeights: [options.roundHeaderHeight],
1522
+ elementHeight: options.roundHeaderHeight,
1523
+ component: config.components.roundHeader,
1524
+ round,
1525
+ roundSwissGroup: null,
1526
+ }, {
1527
+ type: 'roundHeaderGap',
1528
+ area: '.',
1529
+ partHeights: [roundHeaderGap],
1530
+ elementHeight: roundHeaderGap,
1531
+ });
1532
+ }
1533
+ for (const [matchIndex, match] of matches.entries()) {
1534
+ const isLastMatch = matchIndex === matches.length - 1;
1535
+ // One row per match this one is fed by. A fractional factor has no whole feeder row to align to,
1536
+ // so floor it and let the block padding below absorb the remainder - rounding up instead emits a
1537
+ // row the block height cannot pay for, and the padding turns negative to compensate.
1538
+ const fedMatchCount = Math.floor(matchFactor);
1539
+ const matchRows = [];
1540
+ for (let factorIndex = 0; factorIndex < fedMatchCount; factorIndex++) {
1541
+ matchRows.push(options.matchHeight);
1542
+ if (factorIndex < fedMatchCount - 1)
1543
+ matchRows.push(options.rowGap);
1544
+ }
1545
+ const elementHeight = isFinalRound ? options.finalMatchHeight : options.matchHeight;
1546
+ const blockHeight = Math.max(elementHeight, matchFactor * options.matchHeight + (matchFactor - 1) * options.rowGap);
1547
+ const currentBlockHeight = matchRows.reduce((total, height) => total + height, 0);
1548
+ matchRows[matchRows.length - 1] = (matchRows[matchRows.length - 1] ?? 0) + blockHeight - currentBlockHeight;
1549
+ elementsToCreate.push({
1550
+ type: 'match',
1551
+ area: `m${match.shortId}`,
1552
+ partHeights: matchRows,
1553
+ elementHeight,
1554
+ component: isFinalRound ? config.components.finalMatch : config.components.match,
1555
+ match,
1556
+ round,
1557
+ roundSwissGroup: null,
1558
+ });
1559
+ if (!isLastMatch) {
1560
+ elementsToCreate.push({
1561
+ type: 'matchGap',
1562
+ area: '.',
1563
+ partHeights: [options.rowGap],
1564
+ elementHeight: options.rowGap,
1565
+ });
1566
+ }
1567
+ }
1568
+ if (config.bottomPadding) {
1569
+ elementsToCreate.push({
1570
+ type: 'colGap',
1571
+ area: '.',
1572
+ partHeights: [config.bottomPadding],
1573
+ elementHeight: config.bottomPadding,
1574
+ });
1575
+ }
1576
+ for (const elementData of elementsToCreate) {
1577
+ const element = createBracketElement(elementData);
1578
+ pushElement(element);
1579
+ }
1580
+ return subColumn;
1581
+ };
1582
+
1583
+ const heightOfElement = (element) => element.parts.reduce((total, part) => total + part.dimensions.height, 0);
1584
+ const cardGeometry = (subColumn) => {
1585
+ let height = 0;
1586
+ let cardTop = null;
1587
+ for (const element of subColumn.elements) {
1588
+ const elementHeight = heightOfElement(element);
1589
+ if (element.type === 'match' && cardTop === null) {
1590
+ cardTop = height + (elementHeight - element.dimensions.height) / 2;
1591
+ }
1592
+ height += elementHeight;
1593
+ }
1594
+ return { cardTop: cardTop ?? 0, height };
1595
+ };
1596
+ /**
1597
+ * The third place match as a second section of the final's master column: one card the given number of
1598
+ * px below the top of the final's, with its own round header above it.
1599
+ *
1600
+ * @internal
1601
+ */
1602
+ const createFoldedThirdPlaceSection = (config) => {
1603
+ const { options, round } = config;
1604
+ const final = cardGeometry(config.finalSubColumn);
1605
+ const headerOffset = options.roundHeaderHeight > 0 ? options.roundHeaderHeight + options.roundHeaderGap : 0;
1606
+ // The section stacks below the final's, so what it has to make up is the offset less the room the
1607
+ // final's own section already takes. Negative when the fold lands inside that room, which is the
1608
+ // ordinary case: the final's block owns the rows of every round feeding it, so it is far taller
1609
+ // than its card.
1610
+ const paddingTop = final.cardTop + config.topOffset - final.height - headerOffset;
1611
+ const { masterColumnSection, pushSubColumn } = createBracketMasterColumnSection({
1612
+ type: 'round',
1613
+ padding: { top: paddingTop, bottom: 0, left: 0, right: 0 },
1614
+ });
1615
+ pushSubColumn(createRoundBracketSubColumnRelativeToFirstRound({
1616
+ firstRound: round,
1617
+ round,
1618
+ options,
1619
+ hasReverseFinal: false,
1620
+ // Placed by its offset, so it owns one row rather than the block a squeezed round would give it.
1621
+ matchFactor: 1,
1622
+ span: { isStart: true, isEnd: true },
1623
+ components: config.components,
1624
+ }));
1625
+ return masterColumnSection;
1626
+ };
1627
+
1628
+ const createBracketGapMasterColumn = (config) => {
1629
+ const { existingMasterColumns, columnGap } = config;
1630
+ const lastMasterColumn = existingMasterColumns[existingMasterColumns.length - 1];
1631
+ if (!lastMasterColumn) {
1632
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'No last master column found in existing master columns');
1633
+ }
1634
+ const { masterColumn, pushSection } = createBracketMasterColumn({
1635
+ columnWidth: columnGap,
1636
+ padding: {
1637
+ bottom: 0,
1638
+ left: 0,
1639
+ right: 0,
1640
+ top: 0,
1641
+ },
1642
+ });
1643
+ for (const section of lastMasterColumn.sections) {
1644
+ const { masterColumnSection, pushSubColumn } = createBracketMasterColumnSection({
1645
+ type: 'gap',
1646
+ padding: section.padding && { ...section.padding, left: 0, right: 0 },
1647
+ });
1648
+ const lastSubColumn = section.subColumns[section.subColumns.length - 1];
1649
+ if (!lastSubColumn) {
1650
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'No last sub column found in section');
1651
+ }
1652
+ const { pushElement, subColumn } = createBracketSubColumn({
1653
+ span: {
1654
+ isStart: lastSubColumn.span.isEnd ? true : false,
1655
+ isEnd: lastSubColumn.span.isEnd ? true : false,
1656
+ },
1657
+ });
1658
+ for (const lastSubColumnElement of lastSubColumn.elements) {
1659
+ const partHeights = lastSubColumnElement.parts.map((part) => part.dimensions.height);
1660
+ const elementToCreate = (() => {
1661
+ switch (lastSubColumnElement.type) {
1662
+ case 'matchGap':
1663
+ case 'roundHeaderGap':
1664
+ case 'roundGap':
1665
+ case 'colGap': {
1666
+ return {
1667
+ area: !lastSubColumn.span.isEnd ? lastSubColumnElement.area : `.`,
1668
+ type: !lastSubColumn.span.isEnd ? lastSubColumnElement.type : 'colGap',
1669
+ elementHeight: lastSubColumnElement.dimensions.height,
1670
+ partHeights,
1671
+ };
1672
+ }
1673
+ case 'header':
1674
+ if (lastSubColumn.span.isEnd)
1675
+ break;
1676
+ return {
1677
+ area: lastSubColumnElement.area,
1678
+ type: lastSubColumnElement.type,
1679
+ elementHeight: lastSubColumnElement.dimensions.height,
1680
+ partHeights,
1681
+ component: lastSubColumnElement.component,
1682
+ round: lastSubColumnElement.round,
1683
+ roundSwissGroup: lastSubColumnElement.roundSwissGroup,
1684
+ };
1685
+ case 'match':
1686
+ if (lastSubColumn.span.isEnd)
1687
+ break;
1688
+ return {
1689
+ area: lastSubColumnElement.area,
1690
+ type: lastSubColumnElement.type,
1691
+ elementHeight: lastSubColumnElement.dimensions.height,
1692
+ partHeights,
1693
+ component: lastSubColumnElement.component,
1694
+ round: lastSubColumnElement.round,
1695
+ match: lastSubColumnElement.match,
1696
+ roundSwissGroup: lastSubColumnElement.roundSwissGroup,
1697
+ };
1698
+ }
1699
+ return {
1700
+ area: `.`,
1701
+ type: 'colGap',
1702
+ elementHeight: lastSubColumnElement.dimensions.height,
1703
+ partHeights,
1704
+ };
1705
+ })();
1706
+ pushElement(createBracketElement(elementToCreate));
1707
+ }
1708
+ pushSubColumn(subColumn);
1709
+ pushSection(masterColumnSection);
1710
+ }
1711
+ return masterColumn;
1712
+ };
1713
+
1714
+ const resolveBracketGridRowSpan = (bracket, options) => {
1715
+ if (!options.rowSpanRoundId)
1716
+ return options;
1717
+ const round = Array.from(bracket.rounds.values()).find((candidate) => candidate.id === options.rowSpanRoundId || candidate.id.startsWith(`${options.rowSpanRoundId}--half-`));
1718
+ return round ? { ...options, rowSpanMatchCount: round.matchCount } : options;
1719
+ };
1720
+
1721
+ const createDoubleEliminationGrid = (bracketData, options, components) => {
1722
+ const resolvedOptions = resolveBracketGridRowSpan(bracketData, options);
1723
+ const grid = createBracketGrid({ spanElementWidth: resolvedOptions.columnWidth });
1724
+ const presentUpperBracketRounds = Array.from(bracketData.roundsByType.get(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.UPPER_BRACKET)?.values() ?? []);
1725
+ const lowerBracketRounds = Array.from(bracketData.roundsByType.get(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET)?.values() ?? []);
1726
+ // A complete winner bracket has (lowerRounds / 2) + 1 rounds. If fewer are present, the winner
1727
+ // bracket starts later than round 1 - e.g. a small double elimination where lower-round 1 is
1728
+ // seeded directly from a group phase and has no winner round feeding it. Pad the front of the
1729
+ // winner rounds with nulls so the column mapping keeps treating the winner bracket as complete:
1730
+ // the missing early rounds become empty slots above the lower rounds they never fed, and every
1731
+ // present winner round lines up above its real drop-in lower round.
1732
+ const fullUpperRoundCount = Math.floor(lowerBracketRounds.length / 2) + 1;
1733
+ const frontMissingUpperRounds = Math.max(0, fullUpperRoundCount - presentUpperBracketRounds.length);
1734
+ const upperBracketRounds = [
1735
+ ...Array.from({ length: frontMissingUpperRounds }, () => null),
1736
+ ...presentUpperBracketRounds,
1737
+ ];
1738
+ const remainingRounds = Array.from(bracketData.rounds.values()).filter((r) => r.type !== DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.UPPER_BRACKET &&
1739
+ r.type !== DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET &&
1740
+ r.type !== COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE);
1741
+ const thirdPlaceRound = bracketData.roundsByType.get(COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE)?.first() ?? null;
1742
+ const hasReverseFinal = !!bracketData.roundsByType.get(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL)?.first();
1743
+ const firstUpperRound = presentUpperBracketRounds[0];
1744
+ const firstLowerRound = lowerBracketRounds[0];
1745
+ if (!firstUpperRound || !firstLowerRound) {
1746
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.ROUND_RELATION_INVALID, 'No upper or lower rounds found in bracket data');
1747
+ }
1748
+ const upperToLowerRatio = calculateUpperLowerRatio(upperBracketRounds.length, lowerBracketRounds.length);
1749
+ const columnSplitFactor = calculateColumnSplitFactor(upperToLowerRatio);
1750
+ /**
1751
+ * The winner-bracket half of one column, or the space it would have taken. A front-truncated winner
1752
+ * bracket has leading slots with no round in them; reserving their height keeps the lower rounds below
1753
+ * aligned with the winner rounds that do exist.
1754
+ */
1755
+ const createUpperBracketSubColumn = (config) => {
1756
+ if (config.round) {
1757
+ return createRoundBracketSubColumnRelativeToFirstRound({
1758
+ firstRound: firstUpperRound,
1759
+ round: config.round,
1760
+ options: resolvedOptions,
1761
+ hasReverseFinal,
1762
+ span: config.span,
1763
+ components,
1764
+ });
1765
+ }
1766
+ const emptyUpperHeight = (options.roundHeaderHeight > 0 ? options.roundHeaderHeight + options.roundHeaderGap : 0) +
1767
+ firstUpperRound.matchCount * options.matchHeight +
1768
+ Math.max(0, firstUpperRound.matchCount - 1) * options.rowGap;
1769
+ const { subColumn, pushElement } = createBracketSubColumn({ span: config.span });
1770
+ const element = createBracketElement({
1771
+ area: '.',
1772
+ type: 'colGap',
1773
+ elementHeight: emptyUpperHeight,
1774
+ partHeights: [emptyUpperHeight],
1775
+ });
1776
+ pushElement(element);
1777
+ return subColumn;
1778
+ };
1779
+ /** The breathing room between the winner and loser halves of a column. */
1780
+ const createUpperLowerGapSubColumn = () => {
1781
+ const { subColumn, pushElement } = createBracketSubColumn({
1782
+ span: { isStart: true, isEnd: true },
1783
+ });
1784
+ const element = createBracketElement({
1785
+ area: '.',
1786
+ type: 'roundGap',
1787
+ elementHeight: options.rowRoundGap,
1788
+ partHeights: [options.rowRoundGap],
1789
+ });
1790
+ pushElement(element);
1791
+ return subColumn;
1792
+ };
1793
+ let lastRoundLastSubColumnUpperIndex = -1;
1794
+ let lastRoundLastSubColumnLowerIndex = -1;
1795
+ for (const [lowerRoundIndex, lowerRound] of lowerBracketRounds.entries()) {
1796
+ const isLastLowerRound = lowerRoundIndex === lowerBracketRounds.length - 1;
1797
+ const { masterColumn, pushSection } = createBracketMasterColumn({
1798
+ columnWidth: options.columnWidth,
1799
+ padding: {
1800
+ bottom: 0,
1801
+ left: 0,
1802
+ right: 0,
1803
+ top: 0,
1804
+ },
1805
+ });
1806
+ const { masterColumnSection: upperSection, pushSubColumn: pushUpperSubColumn } = createBracketMasterColumnSection({
1807
+ type: 'round',
1808
+ });
1809
+ const { masterColumnSection: upperLowerGapSection, pushSubColumn: pushUpperLowerSubColumn } = createBracketMasterColumnSection({
1810
+ type: 'gap',
1811
+ });
1812
+ const { masterColumnSection: lowerSection, pushSubColumn: pushLowerSubColumn } = createBracketMasterColumnSection({
1813
+ type: 'round',
1814
+ });
1815
+ for (let currentColumnSplitFactor = 1; currentColumnSplitFactor <= columnSplitFactor; currentColumnSplitFactor++) {
1816
+ const subColumnIndex = lowerRoundIndex * columnSplitFactor + (currentColumnSplitFactor - 1);
1817
+ const currentUpperRoundIndex = calculateUpperRoundIndex(subColumnIndex, upperToLowerRatio, columnSplitFactor);
1818
+ const currentLowerRoundIndex = lowerRoundIndex;
1819
+ const isFirstSubColumnInMasterColumn = currentColumnSplitFactor === 1;
1820
+ const isLastSubColumnInMasterColumn = currentColumnSplitFactor === columnSplitFactor;
1821
+ const previousSubColumnIndex = subColumnIndex - 1;
1822
+ const nextSubColumnIndex = subColumnIndex + 1;
1823
+ const previousUpperRoundIndex = previousSubColumnIndex >= 0
1824
+ ? calculateUpperRoundIndex(previousSubColumnIndex, upperToLowerRatio, columnSplitFactor)
1825
+ : -1;
1826
+ const nextUpperRoundIndex = calculateUpperRoundIndex(nextSubColumnIndex, upperToLowerRatio, columnSplitFactor);
1827
+ const previousLowerRoundIndex = previousSubColumnIndex >= 0 ? calculateLowerRoundIndex(previousSubColumnIndex, columnSplitFactor) : -1;
1828
+ const nextLowerRoundIndex = calculateLowerRoundIndex(nextSubColumnIndex, columnSplitFactor);
1829
+ if (currentUpperRoundIndex < 0 || currentUpperRoundIndex >= upperBracketRounds.length) {
1830
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'Upper round not found for subColumnIndex: ' + subColumnIndex);
1831
+ }
1832
+ // May be null for a leading winner slot that does not exist (front-truncated winner bracket).
1833
+ const upperRound = upperBracketRounds[currentUpperRoundIndex];
1834
+ const isUpperSpanStart = isFirstSubColumnInMasterColumn
1835
+ ? lowerRoundIndex === 0 || lastRoundLastSubColumnUpperIndex !== currentUpperRoundIndex
1836
+ : previousUpperRoundIndex !== currentUpperRoundIndex;
1837
+ const isUpperSpanEnd = isLastSubColumnInMasterColumn
1838
+ ? isLastLowerRound ||
1839
+ calculateUpperRoundIndex((lowerRoundIndex + 1) * columnSplitFactor, upperToLowerRatio, columnSplitFactor) !==
1840
+ currentUpperRoundIndex
1841
+ : nextUpperRoundIndex !== currentUpperRoundIndex;
1842
+ const isLowerSpanStart = isFirstSubColumnInMasterColumn
1843
+ ? lowerRoundIndex === 0 || lastRoundLastSubColumnLowerIndex !== currentLowerRoundIndex
1844
+ : previousLowerRoundIndex !== currentLowerRoundIndex;
1845
+ const isLowerSpanEnd = isLastSubColumnInMasterColumn
1846
+ ? isLastLowerRound ||
1847
+ calculateLowerRoundIndex((lowerRoundIndex + 1) * columnSplitFactor, columnSplitFactor) !==
1848
+ currentLowerRoundIndex
1849
+ : nextLowerRoundIndex !== currentLowerRoundIndex;
1850
+ const upperSpan = { isStart: isUpperSpanStart, isEnd: isUpperSpanEnd };
1851
+ pushUpperSubColumn(createUpperBracketSubColumn({ round: upperRound ?? null, span: upperSpan }));
1852
+ pushUpperLowerSubColumn(createUpperLowerGapSubColumn());
1853
+ const lowerSubColumn = createRoundBracketSubColumnRelativeToFirstRound({
1854
+ firstRound: firstLowerRound,
1855
+ round: lowerRound,
1856
+ options: resolvedOptions,
1857
+ hasReverseFinal,
1858
+ span: {
1859
+ isStart: isLowerSpanStart,
1860
+ isEnd: isLowerSpanEnd,
1861
+ },
1862
+ components,
1863
+ });
1864
+ pushLowerSubColumn(lowerSubColumn);
1865
+ if (isLastSubColumnInMasterColumn) {
1866
+ lastRoundLastSubColumnUpperIndex = currentUpperRoundIndex;
1867
+ lastRoundLastSubColumnLowerIndex = currentLowerRoundIndex;
1868
+ }
1869
+ }
1870
+ pushSection(upperSection, upperLowerGapSection, lowerSection);
1871
+ grid.pushMasterColumn(masterColumn);
1872
+ // Always separate consecutive lower rounds with a gap column. The trailing gap after the
1873
+ // last lower round is only needed to connect to the finals section (remainingRounds); in a
1874
+ // truncated bracket without a final we must still gap the intermediate rounds, otherwise the
1875
+ // rounds sit flush against each other and their connector lines collapse to zero width.
1876
+ if (!isLastLowerRound || remainingRounds.length) {
1877
+ grid.pushMasterColumn(createBracketGapMasterColumn({
1878
+ existingMasterColumns: grid.grid.masterColumns,
1879
+ columnGap: options.columnGap,
1880
+ }));
1881
+ }
1882
+ }
1883
+ for (const [roundIndex, round] of remainingRounds.entries()) {
1884
+ const isLastRound = roundIndex === remainingRounds.length - 1;
1885
+ const isFirstRound = roundIndex === 0;
1886
+ const isAnyFinal = hasReverseFinal
1887
+ ? round.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL
1888
+ : round.type === COMMON_BRACKET_ROUND_TYPE.FINAL;
1889
+ const { masterColumn, pushSection } = createBracketMasterColumn({
1890
+ columnWidth: isAnyFinal ? options.finalColumnWidth : options.columnWidth,
1891
+ padding: {
1892
+ bottom: 0,
1893
+ left: 0,
1894
+ right: 0,
1895
+ top: 0,
1896
+ },
1897
+ });
1898
+ const { masterColumnSection: upperSection, pushSubColumn: pushUpperSubColumn } = createBracketMasterColumnSection({
1899
+ type: 'round',
1900
+ });
1901
+ const { masterColumnSection: upperLowerGapSection, pushSubColumn: pushUpperLowerSubColumn } = createBracketMasterColumnSection({
1902
+ type: 'gap',
1903
+ });
1904
+ const { masterColumnSection: lowerSection, pushSubColumn: pushLowerSubColumn } = createBracketMasterColumnSection({
1905
+ type: 'round',
1906
+ });
1907
+ const upperSubColumn = createRoundBracketSubColumnRelativeToFirstRound({
1908
+ firstRound: firstUpperRound,
1909
+ round,
1910
+ options: resolvedOptions,
1911
+ hasReverseFinal,
1912
+ span: {
1913
+ isStart: true,
1914
+ isEnd: true,
1915
+ },
1916
+ components,
1917
+ });
1918
+ pushUpperSubColumn(upperSubColumn);
1919
+ const upperLowerGapSubColumn = createBracketSubColumn({
1920
+ span: {
1921
+ isStart: true,
1922
+ isEnd: true,
1923
+ },
1924
+ });
1925
+ const upperLowerGapElement = createBracketElement({
1926
+ area: '.',
1927
+ type: 'roundGap',
1928
+ elementHeight: options.rowRoundGap,
1929
+ partHeights: [options.rowRoundGap],
1930
+ });
1931
+ upperLowerGapSubColumn.pushElement(upperLowerGapElement);
1932
+ pushUpperLowerSubColumn(upperLowerGapSubColumn.subColumn);
1933
+ if (thirdPlaceRound) {
1934
+ const lowerSubColumn = createRoundBracketSubColumnRelativeToFirstRound({
1935
+ firstRound: firstLowerRound,
1936
+ round: thirdPlaceRound,
1937
+ options: resolvedOptions,
1938
+ hasReverseFinal,
1939
+ span: {
1940
+ isStart: isFirstRound,
1941
+ isEnd: isLastRound,
1942
+ },
1943
+ components,
1944
+ });
1945
+ pushLowerSubColumn(lowerSubColumn);
1946
+ }
1947
+ else {
1948
+ const lowerSubColumn = createBracketSubColumn({
1949
+ span: {
1950
+ isStart: true,
1951
+ isEnd: true,
1952
+ },
1953
+ });
1954
+ const firstMasterRound = grid.grid.masterColumns[0];
1955
+ if (!firstMasterRound)
1956
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'No first master round found in grid');
1957
+ const lastMasterColumnSection = firstMasterRound.sections[firstMasterRound.sections.length - 1];
1958
+ if (!lastMasterColumnSection)
1959
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'No last master column section found in grid');
1960
+ const firstSubColumn = lastMasterColumnSection.subColumns[0];
1961
+ if (!firstSubColumn)
1962
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.GRID_INVALID, 'No first sub column found in grid');
1963
+ for (const element of firstSubColumn.elements) {
1964
+ const el = createBracketElement({
1965
+ area: '.',
1966
+ type: 'colGap',
1967
+ elementHeight: element.dimensions.height,
1968
+ partHeights: element.parts.map((p) => p.dimensions.height),
1969
+ });
1970
+ lowerSubColumn.pushElement(el);
1971
+ }
1972
+ pushLowerSubColumn(lowerSubColumn.subColumn);
1973
+ }
1974
+ pushSection(upperSection, upperLowerGapSection, lowerSection);
1975
+ grid.pushMasterColumn(masterColumn);
1976
+ if (!isLastRound) {
1977
+ grid.pushMasterColumn(createBracketGapMasterColumn({
1978
+ existingMasterColumns: grid.grid.masterColumns,
1979
+ columnGap: options.columnGap,
1980
+ }));
1981
+ }
1982
+ }
1983
+ if (options.continueElement && components.continue) {
1984
+ const continueMatches = getBracketContinueMatches(bracketData);
1985
+ if (continueMatches.length) {
1986
+ grid.pushMasterColumn(createBracketGapMasterColumn({
1987
+ existingMasterColumns: grid.grid.masterColumns,
1988
+ columnGap: options.columnGap,
1989
+ }));
1990
+ grid.pushMasterColumn(createBracketContinueMasterColumn({
1991
+ existingMasterColumns: grid.grid.masterColumns,
1992
+ columnWidth: options.continueElement.columnWidth,
1993
+ elementHeight: options.continueElement.elementHeight,
1994
+ headerOffset: options.roundHeaderHeight > 0 ? options.roundHeaderHeight + options.roundHeaderGap : 0,
1995
+ component: components.continue,
1996
+ matches: continueMatches,
1997
+ }));
1998
+ }
1999
+ }
2000
+ grid.setupElementSpans();
2001
+ grid.calculateDimensions();
2002
+ const finalizedGrid = finalizeBracketGrid(grid);
2003
+ return {
2004
+ raw: grid,
2005
+ columns: finalizedGrid.columns,
2006
+ matchElementMap: finalizedGrid.elementMap,
2007
+ };
2008
+ };
2009
+
2010
+ const CENTRE_CHAIN_ORDER = {
2011
+ [COMMON_BRACKET_ROUND_TYPE.FINAL]: 1,
2012
+ [DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL]: 2,
2013
+ [COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE]: 3,
2014
+ };
2015
+ const isFinalMatchRound = (round, hasReverseFinal) => hasReverseFinal
2016
+ ? round.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL
2017
+ : round.type === COMMON_BRACKET_ROUND_TYPE.FINAL;
2018
+ const chainRoundHeight = (config) => {
2019
+ const { round, options, hasReverseFinal } = config;
2020
+ const cardHeight = isFinalMatchRound(round, hasReverseFinal) ? options.finalMatchHeight : options.matchHeight;
2021
+ return round.matchCount * cardHeight + Math.max(0, round.matchCount - 1) * options.rowGap;
2022
+ };
2023
+ const createStackedBlock = (config) => {
2024
+ const { rounds, hasReverseFinal } = config;
2025
+ const options = resolveBracketGridRowSpan({ rounds }, config.options);
2026
+ const halves = rounds.filter((round) => !!round.mirrorRoundType);
2027
+ const left = halves
2028
+ .filter((round) => round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.LEFT)
2029
+ .sort((a, b) => a.logicalIndex - b.logicalIndex);
2030
+ const right = halves
2031
+ .filter((round) => round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT)
2032
+ .sort((a, b) => b.logicalIndex - a.logicalIndex);
2033
+ const middles = rounds
2034
+ .filter((round) => !round.mirrorRoundType)
2035
+ .sort((a, b) => (CENTRE_CHAIN_ORDER[a.type] ?? 0) - (CENTRE_CHAIN_ORDER[b.type] ?? 0) || a.logicalIndex - b.logicalIndex);
2036
+ // Where the two halves meet: one depth past the innermost round that had a second half to fold against.
2037
+ const foldDepth = left.length ? Math.max(...left.map((round) => round.logicalIndex)) + 1 : 0;
2038
+ // An odd early round could not halve, so it has no twin on the way back and keeps a slot of its own on
2039
+ // the way out rather than sitting in a middle it is far too wide for.
2040
+ const isEarly = (round) => !CENTRE_CHAIN_ORDER[round.type] && round.logicalIndex < foldDepth;
2041
+ const leftSlots = [...left, ...middles.filter(isEarly)].sort((a, b) => a.logicalIndex - b.logicalIndex);
2042
+ const chain = middles.filter((round) => !isEarly(round));
2043
+ const firstRound = leftSlots[0] ?? chain[0];
2044
+ if (!firstRound)
2045
+ return null;
2046
+ const rows = options.rowSpanMatchCount ?? firstRound.matchCount;
2047
+ const rowsHeight = (count) => count * options.matchHeight + Math.max(0, count - 1) * options.rowGap;
2048
+ const content = rowsHeight(rows);
2049
+ const columnContent = (round) => rowsHeight(Math.max(rows, round.matchCount));
2050
+ let chainHeight = 0;
2051
+ for (const [index, round] of chain.entries()) {
2052
+ chainHeight += (index ? options.rowRoundGap : 0) + chainRoundHeight({ round, options, hasReverseFinal });
2053
+ }
2054
+ const anchorHeight = chain[0] ? chainRoundHeight({ round: chain[0], options, hasReverseFinal }) : 0;
2055
+ // The round both halves converge on sits on the block's vertical centre, which is where the two halves'
2056
+ // own matches sit - so the connectors into it stay straight. A chain too long to hang under it from
2057
+ // there grows the block rather than sliding up: an anchor a few px off centre turns every one of those
2058
+ // connectors into a diagonal, in a drawing where nothing else is.
2059
+ const spacerTop = Math.max(0, (content - anchorHeight) / 2);
2060
+ const body = Math.max(spacerTop + chainHeight, ...[...leftSlots, ...right].map(columnContent));
2061
+ return {
2062
+ firstRound,
2063
+ left: leftSlots,
2064
+ right,
2065
+ chain,
2066
+ options,
2067
+ body,
2068
+ columnContent,
2069
+ spacerTop,
2070
+ spacerBottom: Math.max(0, body - spacerTop - chainHeight),
2071
+ };
2072
+ };
2073
+ /**
2074
+ * Draws a double elimination as two mirrored blocks, one above the other: the winners bracket folded
2075
+ * around its own centre, the losers bracket folded around its own, and nothing but a gap between them.
2076
+ * Each block's deciding rounds hang vertically below the round its two halves converge on.
2077
+ *
2078
+ * @internal
2079
+ */
2080
+ const createStackedDoubleEliminationGrid = (bracketData, options, components) => {
2081
+ const grid = createBracketGrid({ spanElementWidth: options.columnWidth });
2082
+ const roundsOfType = (type) => Array.from(bracketData.roundsByType.get(type)?.values() ?? []);
2083
+ const hasReverseFinal = !!roundsOfType(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL).length;
2084
+ const winners = createStackedBlock({
2085
+ rounds: [
2086
+ ...roundsOfType(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.UPPER_BRACKET),
2087
+ ...roundsOfType(COMMON_BRACKET_ROUND_TYPE.FINAL),
2088
+ ...roundsOfType(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL),
2089
+ ],
2090
+ options,
2091
+ hasReverseFinal,
2092
+ });
2093
+ const losers = createStackedBlock({
2094
+ rounds: [
2095
+ ...roundsOfType(DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET),
2096
+ ...roundsOfType(COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE),
2097
+ ],
2098
+ options,
2099
+ hasReverseFinal,
2100
+ });
2101
+ if (!winners || !losers) {
2102
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.ROUND_RELATION_INVALID, 'No upper or lower rounds found in bracket data');
2103
+ }
2104
+ const blocks = [winners, losers];
2105
+ const headerBlock = options.roundHeaderHeight > 0 ? options.roundHeaderHeight + options.roundHeaderGap : 0;
2106
+ const blockHeight = (block) => headerBlock + block.body;
2107
+ const centreIndex = Math.max(...blocks.map((block) => block.left.length));
2108
+ const columnCount = centreIndex + 1 + Math.max(...blocks.map((block) => block.right.length));
2109
+ const createPlaceholderSubColumn = (block) => {
2110
+ const { subColumn, pushElement } = createBracketSubColumn({
2111
+ span: { isStart: true, isEnd: true },
2112
+ });
2113
+ const height = blockHeight(block);
2114
+ pushElement(createBracketElement({
2115
+ area: '.',
2116
+ type: 'colGap',
2117
+ elementHeight: height,
2118
+ partHeights: [height],
2119
+ }));
2120
+ return subColumn;
2121
+ };
2122
+ const createChainSubColumn = (block) => {
2123
+ const { subColumn, pushElement } = createBracketSubColumn({
2124
+ span: { isStart: true, isEnd: true },
2125
+ });
2126
+ const anchor = block.chain[0];
2127
+ if (!anchor)
2128
+ return createPlaceholderSubColumn(block);
2129
+ const elementsToCreate = [];
2130
+ // One header for the whole chain, naming the round its two halves converge on. The rounds below it
2131
+ // are a vertical run of single matches; a header between each would sit on the line joining them.
2132
+ if (options.roundHeaderHeight > 0) {
2133
+ elementsToCreate.push({
2134
+ type: 'header',
2135
+ area: `h${anchor.shortId}`,
2136
+ partHeights: [options.roundHeaderHeight],
2137
+ elementHeight: options.roundHeaderHeight,
2138
+ component: components.roundHeader,
2139
+ round: anchor,
2140
+ roundSwissGroup: null,
2141
+ }, {
2142
+ type: 'roundHeaderGap',
2143
+ area: '.',
2144
+ partHeights: [options.roundHeaderGap],
2145
+ elementHeight: options.roundHeaderGap,
2146
+ });
2147
+ }
2148
+ if (block.spacerTop > 0) {
2149
+ elementsToCreate.push({
2150
+ type: 'colGap',
2151
+ area: '.',
2152
+ partHeights: [block.spacerTop],
2153
+ elementHeight: block.spacerTop,
2154
+ });
2155
+ }
2156
+ for (const [roundIndex, round] of block.chain.entries()) {
2157
+ if (roundIndex) {
2158
+ elementsToCreate.push({
2159
+ type: 'roundGap',
2160
+ area: '.',
2161
+ partHeights: [options.rowRoundGap],
2162
+ elementHeight: options.rowRoundGap,
2163
+ });
2164
+ }
2165
+ const isFinalCard = isFinalMatchRound(round, hasReverseFinal);
2166
+ const cardHeight = isFinalCard ? options.finalMatchHeight : options.matchHeight;
2167
+ for (const [matchIndex, match] of Array.from(round.matches.values()).entries()) {
2168
+ if (matchIndex) {
2169
+ elementsToCreate.push({
2170
+ type: 'matchGap',
2171
+ area: '.',
2172
+ partHeights: [options.rowGap],
2173
+ elementHeight: options.rowGap,
2174
+ });
2175
+ }
2176
+ elementsToCreate.push({
2177
+ type: 'match',
2178
+ area: `m${match.shortId}`,
2179
+ partHeights: [cardHeight],
2180
+ elementHeight: cardHeight,
2181
+ component: isFinalCard ? components.finalMatch : components.match,
2182
+ match,
2183
+ round,
2184
+ roundSwissGroup: null,
2185
+ });
2186
+ }
2187
+ }
2188
+ if (block.spacerBottom > 0) {
2189
+ elementsToCreate.push({
2190
+ type: 'colGap',
2191
+ area: '.',
2192
+ partHeights: [block.spacerBottom],
2193
+ elementHeight: block.spacerBottom,
2194
+ });
2195
+ }
2196
+ for (const elementData of elementsToCreate) {
2197
+ pushElement(createBracketElement(elementData));
2198
+ }
2199
+ return subColumn;
2200
+ };
2201
+ const createBlockSubColumn = (block, columnIndex) => {
2202
+ const slotIndex = columnIndex - (centreIndex - block.left.length);
2203
+ if (slotIndex === block.left.length)
2204
+ return createChainSubColumn(block);
2205
+ const round = slotIndex >= 0 && slotIndex < block.left.length
2206
+ ? block.left[slotIndex]
2207
+ : block.right[slotIndex - block.left.length - 1];
2208
+ if (!round)
2209
+ return createPlaceholderSubColumn(block);
2210
+ return createRoundBracketSubColumnRelativeToFirstRound({
2211
+ firstRound: block.firstRound,
2212
+ round,
2213
+ options: block.options,
2214
+ hasReverseFinal,
2215
+ bottomPadding: block.body - block.columnContent(round),
2216
+ span: { isStart: true, isEnd: true },
2217
+ components,
2218
+ });
2219
+ };
2220
+ const createBandSubColumn = () => {
2221
+ const { subColumn, pushElement } = createBracketSubColumn({
2222
+ span: { isStart: true, isEnd: true },
2223
+ });
2224
+ pushElement(createBracketElement({
2225
+ area: '.',
2226
+ type: 'roundGap',
2227
+ elementHeight: options.rowRoundGap,
2228
+ partHeights: [options.rowRoundGap],
2229
+ }));
2230
+ return subColumn;
2231
+ };
2232
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
2233
+ const { masterColumn, pushSection } = createBracketMasterColumn({
2234
+ columnWidth: columnIndex === centreIndex ? options.finalColumnWidth : options.columnWidth,
2235
+ padding: { bottom: 0, left: 0, right: 0, top: 0 },
2236
+ });
2237
+ const winnersSection = createBracketMasterColumnSection({ type: 'round' });
2238
+ const bandSection = createBracketMasterColumnSection({ type: 'gap' });
2239
+ const losersSection = createBracketMasterColumnSection({ type: 'round' });
2240
+ winnersSection.pushSubColumn(createBlockSubColumn(winners, columnIndex));
2241
+ bandSection.pushSubColumn(createBandSubColumn());
2242
+ losersSection.pushSubColumn(createBlockSubColumn(losers, columnIndex));
2243
+ pushSection(winnersSection.masterColumnSection, bandSection.masterColumnSection, losersSection.masterColumnSection);
2244
+ grid.pushMasterColumn(masterColumn);
2245
+ if (columnIndex < columnCount - 1) {
2246
+ grid.pushMasterColumn(createBracketGapMasterColumn({
2247
+ existingMasterColumns: grid.grid.masterColumns,
2248
+ columnGap: options.columnGap,
2249
+ }));
2250
+ }
2251
+ }
2252
+ grid.calculateDimensions();
2253
+ const finalizedGrid = finalizeBracketGrid(grid);
2254
+ return {
2255
+ raw: grid,
2256
+ columns: finalizedGrid.columns,
2257
+ matchElementMap: finalizedGrid.elementMap,
2258
+ };
2259
+ };
2260
+
2261
+ const resolveFoldedThirdPlaceRound = (rounds, options) => {
2262
+ if (options.thirdPlaceTopOffset === null || options.thirdPlaceTopOffset === undefined)
2263
+ return null;
2264
+ if (!rounds.some((round) => round.type === COMMON_BRACKET_ROUND_TYPE.FINAL))
2265
+ return null;
2266
+ return rounds.find((round) => round.type === COMMON_BRACKET_ROUND_TYPE.THIRD_PLACE) ?? null;
2267
+ };
2268
+ const createSingleEliminationGrid = (bracketData, options, components) => {
2269
+ const grid = createBracketGrid({ spanElementWidth: options.columnWidth });
2270
+ const rounds = Array.from(bracketData.rounds.values());
2271
+ const firstRound = bracketData.rounds.first();
2272
+ if (!firstRound) {
2273
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.SOURCE_EMPTY, 'No rounds found in bracket data');
2274
+ }
2275
+ const resolvedOptions = resolveBracketGridRowSpan(bracketData, options);
2276
+ const foldedThirdPlaceRound = resolveFoldedThirdPlaceRound(rounds, options);
2277
+ const columnRounds = rounds.filter((round) => round !== foldedThirdPlaceRound);
2278
+ let pushSectionToFinal = null;
2279
+ let finalSubColumn = null;
2280
+ for (const [roundIndex, round] of columnRounds.entries()) {
2281
+ const isLastRound = roundIndex === columnRounds.length - 1;
2282
+ const isFinalRound = round.type === COMMON_BRACKET_ROUND_TYPE.FINAL;
2283
+ const { masterColumn, ...mutableMasterColumn } = createBracketMasterColumn({
2284
+ columnWidth: isFinalRound ? options.finalColumnWidth : options.columnWidth,
2285
+ padding: {
2286
+ bottom: 0,
2287
+ left: 0,
2288
+ right: 0,
2289
+ top: 0,
2290
+ },
2291
+ });
2292
+ const { masterColumnSection, pushSubColumn } = createBracketMasterColumnSection({
2293
+ type: 'round',
2294
+ });
2295
+ const sub = createRoundBracketSubColumnRelativeToFirstRound({
2296
+ firstRound,
2297
+ round,
2298
+ options: resolvedOptions,
2299
+ hasReverseFinal: false,
2300
+ span: {
2301
+ isStart: true,
2302
+ isEnd: true,
2303
+ },
2304
+ components,
2305
+ });
2306
+ pushSubColumn(sub);
2307
+ mutableMasterColumn.pushSection(masterColumnSection);
2308
+ grid.pushMasterColumn(masterColumn);
2309
+ if (isFinalRound) {
2310
+ pushSectionToFinal = mutableMasterColumn.pushSection;
2311
+ finalSubColumn = sub;
2312
+ }
2313
+ if (!isLastRound) {
2314
+ grid.pushMasterColumn(createBracketGapMasterColumn({
2315
+ existingMasterColumns: grid.grid.masterColumns,
2316
+ columnGap: options.columnGap,
2317
+ }));
2318
+ }
2319
+ }
2320
+ // After the loop, so the gap column beside the final mirrors the final's section alone: the folded
2321
+ // round has nothing to its right to stay aligned with.
2322
+ if (foldedThirdPlaceRound && pushSectionToFinal && finalSubColumn) {
2323
+ pushSectionToFinal(createFoldedThirdPlaceSection({
2324
+ finalSubColumn,
2325
+ round: foldedThirdPlaceRound,
2326
+ topOffset: options.thirdPlaceTopOffset ?? 0,
2327
+ options: resolvedOptions,
2328
+ components,
2329
+ }));
2330
+ }
2331
+ if (options.continueElement && components.continue) {
2332
+ const continueMatches = getBracketContinueMatches(bracketData);
2333
+ if (continueMatches.length) {
2334
+ grid.pushMasterColumn(createBracketGapMasterColumn({
2335
+ existingMasterColumns: grid.grid.masterColumns,
2336
+ columnGap: options.columnGap,
2337
+ }));
2338
+ grid.pushMasterColumn(createBracketContinueMasterColumn({
2339
+ existingMasterColumns: grid.grid.masterColumns,
2340
+ columnWidth: options.continueElement.columnWidth,
2341
+ elementHeight: options.continueElement.elementHeight,
2342
+ headerOffset: options.roundHeaderHeight > 0 ? options.roundHeaderHeight + options.roundHeaderGap : 0,
2343
+ component: components.continue,
2344
+ matches: continueMatches,
2345
+ }));
2346
+ }
2347
+ }
2348
+ grid.calculateDimensions();
2349
+ const finalizedGrid = finalizeBracketGrid(grid);
2350
+ return {
2351
+ raw: grid,
2352
+ columns: finalizedGrid.columns,
2353
+ matchElementMap: finalizedGrid.elementMap,
2354
+ };
2355
+ };
2356
+
2357
+ const FALLBACK_MATCH_RELATION_POSITION = -1;
2358
+ const generateMatchPosition = (match, factor) => Math.ceil(match.position * factor);
2359
+ const generatePreviousRoundMatchPositions = (relation, match) => {
2360
+ const feederFactor = relation.currentRound.matchCount / relation.previousRound.matchCount;
2361
+ const feederPositions = [];
2362
+ for (const feeder of relation.previousRound.matches.values()) {
2363
+ if (generateMatchPosition(feeder, feederFactor) === match.position)
2364
+ feederPositions.push(feeder.position);
2365
+ }
2366
+ const first = feederPositions[0];
2367
+ const last = feederPositions[feederPositions.length - 1];
2368
+ if (first !== undefined && last !== undefined) {
2369
+ return { previousUpperRoundMatchPosition: first, previousLowerRoundMatchPosition: last };
2370
+ }
2371
+ const double = relation.previousRoundMatchFactor === 2 ? 1 : 0;
2372
+ return {
2373
+ previousUpperRoundMatchPosition: (generateMatchPosition(match, relation.previousRoundMatchFactor) -
2374
+ double),
2375
+ previousLowerRoundMatchPosition: generateMatchPosition(match, relation.previousRoundMatchFactor),
2376
+ };
2377
+ };
2378
+ const generateMatchRelationPositions = (relation, match) => {
2379
+ switch (relation.type) {
2380
+ case 'none':
2381
+ return null;
2382
+ case 'nothing-to-one':
2383
+ return {
2384
+ nextRoundMatchPosition: generateMatchPosition(match, relation.nextRoundMatchFactor),
2385
+ previousUpperRoundMatchPosition: FALLBACK_MATCH_RELATION_POSITION,
2386
+ previousLowerRoundMatchPosition: FALLBACK_MATCH_RELATION_POSITION,
2387
+ };
2388
+ case 'one-to-nothing':
2389
+ return {
2390
+ nextRoundMatchPosition: FALLBACK_MATCH_RELATION_POSITION,
2391
+ ...generatePreviousRoundMatchPositions(relation, match),
2392
+ };
2393
+ case 'one-to-one':
2394
+ return {
2395
+ nextRoundMatchPosition: generateMatchPosition(match, relation.nextRoundMatchFactor),
2396
+ ...generatePreviousRoundMatchPositions(relation, match),
2397
+ };
2398
+ case 'two-to-one':
2399
+ return {
2400
+ nextRoundMatchPosition: generateMatchPosition(match, relation.nextRoundMatchFactor),
2401
+ previousUpperRoundMatchPosition: generateMatchPosition(match, relation.previousUpperRoundMatchFactor),
2402
+ previousLowerRoundMatchPosition: generateMatchPosition(match, relation.previousLowerRoundMatchFactor),
2403
+ };
2404
+ case 'two-to-nothing':
2405
+ return {
2406
+ nextRoundMatchPosition: FALLBACK_MATCH_RELATION_POSITION,
2407
+ previousUpperRoundMatchPosition: generateMatchPosition(match, relation.previousUpperRoundMatchFactor),
2408
+ previousLowerRoundMatchPosition: generateMatchPosition(match, relation.previousLowerRoundMatchFactor),
2409
+ };
2410
+ }
2411
+ };
2412
+ const createNothingToOneRelation$1 = (params) => {
2413
+ const { match, relation, matchPositionMaps, nextRoundMatchPosition } = params;
2414
+ const nextMatch = matchPositionMaps.get(relation.nextRound.id)?.get(nextRoundMatchPosition);
2415
+ if (!nextMatch)
2416
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Next round match not found');
2417
+ return {
2418
+ type: 'nothing-to-one',
2419
+ currentMatch: match,
2420
+ currentRound: relation.currentRound,
2421
+ nextMatch,
2422
+ nextRound: relation.nextRound,
2423
+ };
2424
+ };
2425
+ const createOneToNothingOrTwoToNothingRelation = (params) => {
2426
+ const { match, relation, matchPositionMaps, previousUpperRoundMatchPosition, previousLowerRoundMatchPosition } = params;
2427
+ const previousUpperMatch = matchPositionMaps.get(relation.previousRound.id)?.get(previousUpperRoundMatchPosition);
2428
+ const previousLowerMatch = matchPositionMaps.get(relation.previousRound.id)?.get(previousLowerRoundMatchPosition);
2429
+ if (!previousUpperMatch)
2430
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous round match not found');
2431
+ if (previousUpperRoundMatchPosition !== previousLowerRoundMatchPosition) {
2432
+ if (!previousLowerMatch)
2433
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous lower round match not found');
2434
+ return {
2435
+ type: 'two-to-nothing',
2436
+ currentMatch: match,
2437
+ currentRound: relation.currentRound,
2438
+ previousUpperMatch,
2439
+ previousUpperRound: relation.previousRound,
2440
+ previousLowerMatch,
2441
+ previousLowerRound: relation.previousRound,
2442
+ };
2443
+ }
2444
+ else {
2445
+ return {
2446
+ type: 'one-to-nothing',
2447
+ currentMatch: match,
2448
+ currentRound: relation.currentRound,
2449
+ previousMatch: previousUpperMatch,
2450
+ previousRound: relation.previousRound,
2451
+ };
2452
+ }
2453
+ };
2454
+ const createOneToOneOrTwoToOneRelation = (params) => {
2455
+ const { match, relation, matchPositionMaps, nextRoundMatchPosition, previousUpperRoundMatchPosition, previousLowerRoundMatchPosition, } = params;
2456
+ const nextMatch = matchPositionMaps.get(relation.nextRound.id)?.get(nextRoundMatchPosition);
2457
+ const previousUpperMatch = matchPositionMaps.get(relation.previousRound.id)?.get(previousUpperRoundMatchPosition);
2458
+ const previousLowerMatch = matchPositionMaps.get(relation.previousRound.id)?.get(previousLowerRoundMatchPosition);
2459
+ if (!nextMatch)
2460
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Next round match not found');
2461
+ if (!previousUpperMatch)
2462
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous upper round match not found');
2463
+ if (!previousLowerMatch)
2464
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous lower round match not found');
2465
+ if (previousUpperRoundMatchPosition === previousLowerRoundMatchPosition) {
2466
+ return {
2467
+ type: 'one-to-one',
2468
+ currentMatch: match,
2469
+ currentRound: relation.currentRound,
2470
+ previousMatch: previousUpperMatch,
2471
+ previousRound: relation.previousRound,
2472
+ nextMatch,
2473
+ nextRound: relation.nextRound,
2474
+ };
2475
+ }
2476
+ else {
2477
+ return {
2478
+ type: 'two-to-one',
2479
+ currentMatch: match,
2480
+ currentRound: relation.currentRound,
2481
+ previousUpperMatch,
2482
+ previousUpperRound: relation.previousRound,
2483
+ previousLowerMatch,
2484
+ previousLowerRound: relation.previousRound,
2485
+ nextMatch,
2486
+ nextRound: relation.nextRound,
2487
+ };
2488
+ }
2489
+ };
2490
+ const createTwoToOneRelation$1 = (params) => {
2491
+ const { match, relation, matchPositionMaps, nextRoundMatchPosition, previousUpperRoundMatchPosition, previousLowerRoundMatchPosition, } = params;
2492
+ const nextMatch = matchPositionMaps.get(relation.nextRound.id)?.get(nextRoundMatchPosition);
2493
+ const previousUpperMatch = matchPositionMaps
2494
+ .get(relation.previousUpperRound.id)
2495
+ ?.get(previousUpperRoundMatchPosition);
2496
+ const previousLowerMatch = matchPositionMaps
2497
+ .get(relation.previousLowerRound.id)
2498
+ ?.get(previousLowerRoundMatchPosition);
2499
+ if (!nextMatch)
2500
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Next round match not found');
2501
+ if (!previousUpperMatch)
2502
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous upper round match not found');
2503
+ if (!previousLowerMatch)
2504
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous lower round match not found');
2505
+ return {
2506
+ type: 'two-to-one',
2507
+ currentMatch: match,
2508
+ currentRound: relation.currentRound,
2509
+ previousUpperMatch,
2510
+ previousUpperRound: relation.previousUpperRound,
2511
+ previousLowerMatch,
2512
+ previousLowerRound: relation.previousLowerRound,
2513
+ nextMatch,
2514
+ nextRound: relation.nextRound,
2515
+ };
2516
+ };
2517
+ const createTwoToNothingRelation$1 = (params) => {
2518
+ const { match, relation, matchPositionMaps, previousUpperRoundMatchPosition, previousLowerRoundMatchPosition } = params;
2519
+ const previousUpperMatch = matchPositionMaps
2520
+ .get(relation.previousUpperRound.id)
2521
+ ?.get(previousUpperRoundMatchPosition);
2522
+ const previousLowerMatch = matchPositionMaps
2523
+ .get(relation.previousLowerRound.id)
2524
+ ?.get(previousLowerRoundMatchPosition);
2525
+ if (!previousUpperMatch)
2526
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous upper round match not found');
2527
+ if (!previousLowerMatch)
2528
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.MATCH_RELATION_INVALID, 'Previous lower round match not found');
2529
+ return {
2530
+ type: 'two-to-nothing',
2531
+ currentMatch: match,
2532
+ currentRound: relation.currentRound,
2533
+ previousUpperMatch,
2534
+ previousUpperRound: relation.previousUpperRound,
2535
+ previousLowerMatch,
2536
+ previousLowerRound: relation.previousLowerRound,
2537
+ };
2538
+ };
2539
+ const generateMatchRelationsNew = (bracketData, options = {}) => {
2540
+ const sourceMatches = options.source?.matches ?? [];
2541
+ const hasDeclaredGraph = options.previousMatchIds !== undefined || sourceMatches.some((match) => matchOutcomeSlots(match).length > 0);
2542
+ if (hasDeclaredGraph) {
2543
+ return generateDeclaredMatchRelations({
2544
+ bracket: bracketData,
2545
+ sourceMatches,
2546
+ previousMatchIds: options.previousMatchIds ?? defaultPreviousMatchIds,
2547
+ });
2548
+ }
2549
+ const matchRelations = [];
2550
+ const matchPositionMaps = new Map();
2551
+ for (const round of bracketData.rounds.values()) {
2552
+ const matchMap = new Map([...round.matches.values()].map((m) => [m.position, m]));
2553
+ matchPositionMaps.set(round.id, matchMap);
2554
+ }
2555
+ for (const match of bracketData.matches.values()) {
2556
+ const relation = match.round.relation;
2557
+ const positions = generateMatchRelationPositions(relation, match);
2558
+ if (!positions)
2559
+ continue;
2560
+ const { nextRoundMatchPosition, previousUpperRoundMatchPosition, previousLowerRoundMatchPosition } = positions;
2561
+ switch (relation.type) {
2562
+ case 'nothing-to-one':
2563
+ matchRelations.push(createNothingToOneRelation$1({
2564
+ match,
2565
+ relation,
2566
+ matchPositionMaps,
2567
+ nextRoundMatchPosition,
2568
+ }));
2569
+ break;
2570
+ case 'one-to-nothing':
2571
+ matchRelations.push(createOneToNothingOrTwoToNothingRelation({
2572
+ match,
2573
+ relation,
2574
+ matchPositionMaps,
2575
+ previousUpperRoundMatchPosition,
2576
+ previousLowerRoundMatchPosition,
2577
+ }));
2578
+ break;
2579
+ case 'one-to-one':
2580
+ matchRelations.push(createOneToOneOrTwoToOneRelation({
2581
+ match,
2582
+ relation,
2583
+ matchPositionMaps,
2584
+ nextRoundMatchPosition,
2585
+ previousUpperRoundMatchPosition,
2586
+ previousLowerRoundMatchPosition,
2587
+ }));
2588
+ break;
2589
+ case 'two-to-one':
2590
+ matchRelations.push(createTwoToOneRelation$1({
2591
+ match,
2592
+ relation,
2593
+ matchPositionMaps,
2594
+ nextRoundMatchPosition,
2595
+ previousUpperRoundMatchPosition,
2596
+ previousLowerRoundMatchPosition,
2597
+ }));
2598
+ break;
2599
+ case 'two-to-nothing':
2600
+ matchRelations.push(createTwoToNothingRelation$1({
2601
+ match,
2602
+ relation,
2603
+ matchPositionMaps,
2604
+ previousUpperRoundMatchPosition,
2605
+ previousLowerRoundMatchPosition,
2606
+ }));
2607
+ break;
2608
+ }
2609
+ }
2610
+ return matchRelations;
2611
+ };
2612
+ const matchOutcomeSlots = (match) => [match.homeSource, match.awaySource].filter((slot) => slot?.kind === 'match-outcome' && !!slot.matchId);
2613
+ const defaultPreviousMatchIds = (match) => matchOutcomeSlots(match)
2614
+ .filter((slot) => slot.role !== 'loser')
2615
+ .map((slot) => slot.matchId);
2616
+ const generateDeclaredMatchRelations = (options) => {
2617
+ const { bracket, sourceMatches, previousMatchIds } = options;
2618
+ const previousByMatchId = new Map(sourceMatches.map((match) => [match.id, previousMatchIds(match)]));
2619
+ const nextByMatchId = new Map();
2620
+ // A match can feed two: a semi final feeds the final with its winner and the third place match with
2621
+ // its loser. The winner is where the match leads, so it wins over whatever the source happens to list
2622
+ // first - without this, `nextMatch` answers "where does this winner go" from array order.
2623
+ const winnerConsumerByFeederId = new Map();
2624
+ for (const match of sourceMatches) {
2625
+ for (const slot of [match.homeSource, match.awaySource]) {
2626
+ if (slot?.kind !== 'match-outcome' || slot.role !== 'winner' || !slot.matchId)
2627
+ continue;
2628
+ if (!winnerConsumerByFeederId.has(slot.matchId))
2629
+ winnerConsumerByFeederId.set(slot.matchId, match.id);
2630
+ }
2631
+ }
2632
+ for (const [matchId, feederIds] of previousByMatchId) {
2633
+ for (const feederId of feederIds) {
2634
+ if (winnerConsumerByFeederId.get(feederId) === matchId)
2635
+ nextByMatchId.set(feederId, matchId);
2636
+ else if (!nextByMatchId.has(feederId))
2637
+ nextByMatchId.set(feederId, matchId);
2638
+ }
2639
+ }
2640
+ const relations = [];
2641
+ for (const match of bracket.matches.values()) {
2642
+ const previousMatches = (previousByMatchId.get(match.id) ?? [])
2643
+ .map((id) => bracket.matches.get(id))
2644
+ .filter((candidate) => candidate !== undefined);
2645
+ const nextMatchId = nextByMatchId.get(match.id);
2646
+ const nextMatch = nextMatchId ? bracket.matches.get(nextMatchId) : undefined;
2647
+ if (previousMatches.length === 0 && nextMatch) {
2648
+ relations.push({
2649
+ type: 'nothing-to-one',
2650
+ currentMatch: match,
2651
+ currentRound: match.round,
2652
+ nextMatch,
2653
+ nextRound: nextMatch.round,
2654
+ });
2655
+ }
2656
+ else if (previousMatches.length === 1 && nextMatch) {
2657
+ const previousMatch = previousMatches[0];
2658
+ if (!previousMatch)
2659
+ continue;
2660
+ relations.push({
2661
+ type: 'one-to-one',
2662
+ currentMatch: match,
2663
+ currentRound: match.round,
2664
+ previousMatch,
2665
+ previousRound: previousMatch.round,
2666
+ nextMatch,
2667
+ nextRound: nextMatch.round,
2668
+ });
2669
+ }
2670
+ else if (previousMatches.length >= 2 && nextMatch) {
2671
+ const previousUpperMatch = previousMatches[0];
2672
+ const previousLowerMatch = previousMatches[1];
2673
+ if (!previousUpperMatch || !previousLowerMatch)
2674
+ continue;
2675
+ relations.push({
2676
+ type: 'two-to-one',
2677
+ currentMatch: match,
2678
+ currentRound: match.round,
2679
+ previousUpperMatch,
2680
+ previousUpperRound: previousUpperMatch.round,
2681
+ previousLowerMatch,
2682
+ previousLowerRound: previousLowerMatch.round,
2683
+ nextMatch,
2684
+ nextRound: nextMatch.round,
2685
+ });
2686
+ }
2687
+ else if (previousMatches.length === 1) {
2688
+ const previousMatch = previousMatches[0];
2689
+ if (!previousMatch)
2690
+ continue;
2691
+ relations.push({
2692
+ type: 'one-to-nothing',
2693
+ currentMatch: match,
2694
+ currentRound: match.round,
2695
+ previousMatch,
2696
+ previousRound: previousMatch.round,
2697
+ });
2698
+ }
2699
+ else if (previousMatches.length >= 2) {
2700
+ const previousUpperMatch = previousMatches[0];
2701
+ const previousLowerMatch = previousMatches[1];
2702
+ if (!previousUpperMatch || !previousLowerMatch)
2703
+ continue;
2704
+ relations.push({
2705
+ type: 'two-to-nothing',
2706
+ currentMatch: match,
2707
+ currentRound: match.round,
2708
+ previousUpperMatch,
2709
+ previousUpperRound: previousUpperMatch.round,
2710
+ previousLowerMatch,
2711
+ previousLowerRound: previousLowerMatch.round,
2712
+ });
2713
+ }
2714
+ }
2715
+ return relations;
2716
+ };
2717
+
2718
+ const calculateMatchFactor = (numeratorRound, denominatorRound) => numeratorRound.matchCount / denominatorRound.matchCount;
2719
+ const createNothingToOneRelation = (params) => ({
2720
+ type: 'nothing-to-one',
2721
+ currentRound: params.currentRound,
2722
+ nextRound: params.nextRound,
2723
+ nextRoundMatchFactor: calculateMatchFactor(params.nextRound, params.currentRound),
2724
+ });
2725
+ const createOneToNothingRelation = (params) => ({
2726
+ type: 'one-to-nothing',
2727
+ currentRound: params.currentRound,
2728
+ previousRound: params.previousRound,
2729
+ previousRoundMatchFactor: calculateMatchFactor(params.previousRound, params.currentRound),
2730
+ rootRoundMatchFactor: calculateMatchFactor(params.rootRound, params.currentRound),
2731
+ });
2732
+ const createOneToOneRelation = (params) => ({
2733
+ type: 'one-to-one',
2734
+ currentRound: params.currentRound,
2735
+ previousRound: params.previousRound,
2736
+ nextRound: params.nextRound,
2737
+ nextRoundMatchFactor: calculateMatchFactor(params.nextRound, params.currentRound),
2738
+ previousRoundMatchFactor: calculateMatchFactor(params.previousRound, params.currentRound),
2739
+ rootRoundMatchFactor: calculateMatchFactor(params.rootRound, params.currentRound),
2740
+ });
2741
+ const createTwoToOneRelation = (params) => ({
2742
+ type: 'two-to-one',
2743
+ currentRound: params.currentRound,
2744
+ previousUpperRound: params.previousUpperRound,
2745
+ previousLowerRound: params.previousLowerRound,
2746
+ nextRound: params.nextRound,
2747
+ nextRoundMatchFactor: calculateMatchFactor(params.nextRound, params.currentRound),
2748
+ previousUpperRoundMatchFactor: calculateMatchFactor(params.previousUpperRound, params.currentRound),
2749
+ previousLowerRoundMatchFactor: calculateMatchFactor(params.previousLowerRound, params.currentRound),
2750
+ upperRootRoundMatchFactor: calculateMatchFactor(params.firstUpperRound, params.currentRound),
2751
+ lowerRootRoundMatchFactor: calculateMatchFactor(params.firstLowerRound, params.currentRound),
2752
+ });
2753
+ const createTwoToNothingRelation = (params) => ({
2754
+ type: 'two-to-nothing',
2755
+ currentRound: params.currentRound,
2756
+ previousUpperRound: params.previousUpperRound,
2757
+ previousLowerRound: params.previousLowerRound,
2758
+ previousUpperRoundMatchFactor: calculateMatchFactor(params.previousUpperRound, params.currentRound),
2759
+ previousLowerRoundMatchFactor: calculateMatchFactor(params.previousLowerRound, params.currentRound),
2760
+ upperRootRoundMatchFactor: calculateMatchFactor(params.firstUpperRound, params.currentRound),
2761
+ lowerRootRoundMatchFactor: calculateMatchFactor(params.firstLowerRound, params.currentRound),
2762
+ });
2763
+ /** A round drawn on the way back from the middle of a mirrored bracket. */
2764
+ const isFoldedBackRound = (round) => round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT;
2765
+ const buildRoundsByDepth = (rounds) => {
2766
+ const byDepth = new Map();
2767
+ for (const round of rounds) {
2768
+ const slot = byDepth.get(round.logicalIndex) ?? { left: null, right: null, single: null };
2769
+ if (round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.LEFT)
2770
+ slot.left = round;
2771
+ else if (round.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT)
2772
+ slot.right = round;
2773
+ else
2774
+ slot.single = round;
2775
+ byDepth.set(round.logicalIndex, slot);
2776
+ }
2777
+ return byDepth;
2778
+ };
2779
+ /**
2780
+ * The round one depth along on the same side of the fold - or, where the fold has closed because that
2781
+ * depth was too small to halve, the whole round sitting in the middle.
2782
+ */
2783
+ const roundAtDepth = (params) => {
2784
+ const slot = params.byDepth.get(params.depth);
2785
+ if (!slot)
2786
+ return null;
2787
+ const sameSide = params.side === BRACKET_ROUND_MIRROR_TYPE.RIGHT ? slot.right : slot.left;
2788
+ return sameSide ?? slot.single ?? null;
2789
+ };
2790
+ const getNavigationContext = (params) => {
2791
+ const { upperRounds, upperRoundsByDepth, currentUpperRoundIndex } = params;
2792
+ const currentUpperRound = upperRounds[currentUpperRoundIndex];
2793
+ if (!currentUpperRound)
2794
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.ROUND_RELATION_INVALID, 'currentUpperRound is null');
2795
+ const isLeftToRight = !currentUpperRound.mirrorRoundType || currentUpperRound.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.LEFT;
2796
+ // A folded half follows the fold, not the array. Its neighbours are the rounds one depth away on its
2797
+ // own side, because the array puts a right half next to whatever happened to be emitted beside it -
2798
+ // in a double elimination that is the grand final rather than the round it actually feeds.
2799
+ const isFolded = !!currentUpperRound.mirrorRoundType;
2800
+ const side = currentUpperRound.mirrorRoundType;
2801
+ const depth = currentUpperRound.logicalIndex;
2802
+ const relativePrevious = isFolded
2803
+ ? roundAtDepth({ byDepth: upperRoundsByDepth, depth: depth - 1, side })
2804
+ : upperRounds[currentUpperRoundIndex - 1] || null;
2805
+ const relativeNext = isFolded
2806
+ ? roundAtDepth({ byDepth: upperRoundsByDepth, depth: depth + 1, side })
2807
+ : upperRounds[currentUpperRoundIndex + 1] || null;
2808
+ // A folded half already resolved its own direction, so the swap that turns array order into flow
2809
+ // order for a right-hand round must not be applied twice.
2810
+ const previousUpperRound = isFolded || isLeftToRight ? relativePrevious : relativeNext;
2811
+ const nextUpperRound = isFolded || isLeftToRight ? relativeNext : relativePrevious;
2812
+ const isLastUpperRound = !nextUpperRound ||
2813
+ (nextUpperRound.mirrorRoundType === BRACKET_ROUND_MIRROR_TYPE.RIGHT && !currentUpperRound.mirrorRoundType);
2814
+ const isFinal = currentUpperRound.type === COMMON_BRACKET_ROUND_TYPE.FINAL;
2815
+ return {
2816
+ currentUpperRound,
2817
+ previousUpperRound,
2818
+ nextUpperRound,
2819
+ isLastUpperRound,
2820
+ isFinal,
2821
+ };
2822
+ };
2823
+ const handleFinalRound = (params) => {
2824
+ const { relations, currentUpperRound, previousUpperRound, nextUpperRound, lowerRounds, firstUpperRound, firstLowerRound, lastLowerRound, } = params;
2825
+ const finalLowerRound = lastLowerRound;
2826
+ const isAsyncBracket = nextUpperRound?.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.REVERSE_FINAL;
2827
+ if (nextUpperRound) {
2828
+ relations.push(createTwoToOneRelation({
2829
+ currentRound: currentUpperRound,
2830
+ previousUpperRound,
2831
+ previousLowerRound: finalLowerRound,
2832
+ nextRound: nextUpperRound,
2833
+ firstUpperRound,
2834
+ firstLowerRound,
2835
+ }));
2836
+ }
2837
+ else {
2838
+ relations.push(createTwoToNothingRelation({
2839
+ currentRound: currentUpperRound,
2840
+ previousUpperRound,
2841
+ previousLowerRound: finalLowerRound,
2842
+ firstUpperRound,
2843
+ firstLowerRound,
2844
+ }));
2845
+ }
2846
+ const preFinalLowerRound = lowerRounds[lowerRounds.length - 2] || null;
2847
+ if (!preFinalLowerRound) {
2848
+ relations.push(createNothingToOneRelation({ currentRound: finalLowerRound, nextRound: currentUpperRound }));
2849
+ return;
2850
+ }
2851
+ relations.push(createOneToOneRelation({
2852
+ currentRound: finalLowerRound,
2853
+ previousRound: preFinalLowerRound,
2854
+ nextRound: currentUpperRound,
2855
+ rootRound: firstLowerRound,
2856
+ }));
2857
+ if (!isAsyncBracket)
2858
+ return;
2859
+ const prePreFinalLowerRound = lowerRounds[lowerRounds.length - 3] || null;
2860
+ relations.push(prePreFinalLowerRound
2861
+ ? createOneToOneRelation({
2862
+ currentRound: preFinalLowerRound,
2863
+ previousRound: prePreFinalLowerRound,
2864
+ nextRound: finalLowerRound,
2865
+ rootRound: firstLowerRound,
2866
+ })
2867
+ : createNothingToOneRelation({ currentRound: preFinalLowerRound, nextRound: finalLowerRound }));
2868
+ };
2869
+ const handleFirstRound = (params) => {
2870
+ const { relations, currentUpperRound, nextUpperRound, lowerRounds, currentUpperRoundIndex } = params;
2871
+ relations.push(createNothingToOneRelation({
2872
+ currentRound: currentUpperRound,
2873
+ nextRound: nextUpperRound,
2874
+ }));
2875
+ const currentLowerRound = lowerRounds[currentUpperRoundIndex] || null;
2876
+ const nextLowerRound = lowerRounds[currentUpperRoundIndex + 1] || null;
2877
+ // Pairing "upper round at index i" with "lower round at index i" only means something while the upper
2878
+ // array is the outbound flow. A round on the way back from a fold sits past the finals in that array,
2879
+ // so its index would claim a lower round in the middle of the bracket and wire it as an opening round -
2880
+ // the loop at the end of `generateRoundRelations` reaches it by depth instead.
2881
+ if (currentLowerRound && nextLowerRound && !isFoldedBackRound(currentUpperRound)) {
2882
+ relations.push(createNothingToOneRelation({
2883
+ currentRound: currentLowerRound,
2884
+ nextRound: nextLowerRound,
2885
+ }));
2886
+ }
2887
+ };
2888
+ const handleRegularRound = (params) => {
2889
+ const { relations, currentUpperRound, previousUpperRound, nextUpperRound, lowerRounds, currentUpperRoundIndex, firstUpperRound, firstLowerRound, } = params;
2890
+ relations.push(createOneToOneRelation({
2891
+ currentRound: currentUpperRound,
2892
+ previousRound: previousUpperRound,
2893
+ nextRound: nextUpperRound,
2894
+ rootRound: firstUpperRound,
2895
+ }));
2896
+ const currentLowerRound = lowerRounds[currentUpperRoundIndex] || null;
2897
+ const previousLowerRound = lowerRounds[currentUpperRoundIndex - 1] || null;
2898
+ const nextLowerRound = lowerRounds[currentUpperRoundIndex + 1] || null;
2899
+ if (currentLowerRound &&
2900
+ currentUpperRound.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.UPPER_BRACKET &&
2901
+ previousLowerRound &&
2902
+ nextLowerRound &&
2903
+ firstLowerRound &&
2904
+ // See `handleFirstRound`: an index into the outbound flow says nothing about a folded-back round.
2905
+ !isFoldedBackRound(currentUpperRound)) {
2906
+ relations.push(createOneToOneRelation({
2907
+ currentRound: currentLowerRound,
2908
+ previousRound: previousLowerRound,
2909
+ nextRound: nextLowerRound,
2910
+ rootRound: firstLowerRound,
2911
+ }));
2912
+ }
2913
+ };
2914
+ const generateRoundRelationsNew = (bracketData) => {
2915
+ const relations = [];
2916
+ // A relation may never name a round with no matches: every position in it comes from a ratio of match
2917
+ // counts, so a zero puts each neighbour's match at a position nothing occupies. A round whose matches
2918
+ // are not drawn yet is treated as absent instead, and its neighbours wired to each other.
2919
+ const allRounds = [...bracketData.rounds.values()].filter((round) => round.matchCount > 0);
2920
+ const upperRounds = allRounds.filter((r) => r.type !== DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET);
2921
+ const lowerRounds = allRounds.filter((r) => r.type === DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE.LOWER_BRACKET);
2922
+ const upperRoundsByDepth = buildRoundsByDepth(upperRounds);
2923
+ const lowerRoundsByDepth = buildRoundsByDepth(lowerRounds);
2924
+ // The lower bracket as it is *played*: one round per depth, taking the left half of a folded round.
2925
+ // A fold draws each early round twice, so the raw array holds two entries per depth and its last
2926
+ // element is a first-round half rather than the round that feeds the grand final. Unfolded, this is
2927
+ // the array itself.
2928
+ const lowerFlow = Array.from(lowerRoundsByDepth.keys())
2929
+ .sort((a, b) => a - b)
2930
+ .map((depth) => roundAtDepth({ byDepth: lowerRoundsByDepth, depth, side: null }))
2931
+ .filter((round) => !!round);
2932
+ const firstUpperRound = upperRounds[0];
2933
+ const firstLowerRound = lowerFlow[0] || null;
2934
+ const lastLowerRound = lowerFlow[lowerFlow.length - 1] || null;
2935
+ // Nothing is drawn yet, so there is nothing to relate - not a malformed structure.
2936
+ if (allRounds.length === 0)
2937
+ return relations;
2938
+ if (!firstUpperRound)
2939
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.ROUND_RELATION_INVALID, 'No upper rounds found');
2940
+ const hasLowerRounds = lowerRounds.length > 0;
2941
+ for (const [currentUpperRoundIndex] of upperRounds.entries()) {
2942
+ const nav = getNavigationContext({
2943
+ upperRounds,
2944
+ upperRoundsByDepth,
2945
+ currentUpperRoundIndex,
2946
+ });
2947
+ if (nav.isFinal && hasLowerRounds && lastLowerRound && firstLowerRound && nav.previousUpperRound) {
2948
+ handleFinalRound({
2949
+ relations,
2950
+ currentUpperRound: nav.currentUpperRound,
2951
+ previousUpperRound: nav.previousUpperRound,
2952
+ nextUpperRound: nav.nextUpperRound,
2953
+ lowerRounds: lowerFlow,
2954
+ currentUpperRoundIndex,
2955
+ firstUpperRound,
2956
+ firstLowerRound,
2957
+ lastLowerRound,
2958
+ });
2959
+ }
2960
+ else if (nav.isLastUpperRound && nav.previousUpperRound) {
2961
+ relations.push(createOneToNothingRelation({
2962
+ currentRound: nav.currentUpperRound,
2963
+ previousRound: nav.previousUpperRound,
2964
+ rootRound: firstUpperRound,
2965
+ }));
2966
+ }
2967
+ else if (nav.currentUpperRound.isFirstRound && nav.nextUpperRound) {
2968
+ handleFirstRound({
2969
+ relations,
2970
+ currentUpperRound: nav.currentUpperRound,
2971
+ nextUpperRound: nav.nextUpperRound,
2972
+ lowerRounds: lowerFlow,
2973
+ currentUpperRoundIndex,
2974
+ });
2975
+ }
2976
+ else if (nav.nextUpperRound && !nav.previousUpperRound) {
2977
+ relations.push(createNothingToOneRelation({
2978
+ currentRound: nav.currentUpperRound,
2979
+ nextRound: nav.nextUpperRound,
2980
+ }));
2981
+ }
2982
+ else if (nav.previousUpperRound && nav.nextUpperRound) {
2983
+ handleRegularRound({
2984
+ relations,
2985
+ currentUpperRound: nav.currentUpperRound,
2986
+ previousUpperRound: nav.previousUpperRound,
2987
+ nextUpperRound: nav.nextUpperRound,
2988
+ lowerRounds: lowerFlow,
2989
+ currentUpperRoundIndex,
2990
+ firstUpperRound,
2991
+ firstLowerRound,
2992
+ });
2993
+ }
2994
+ }
2995
+ if (hasLowerRounds && firstLowerRound) {
2996
+ const assignedRoundIds = new Set(relations.map((r) => r.currentRound.id));
2997
+ // By depth and side rather than by array position: in a folded bracket the array holds two rounds
2998
+ // per depth, so its neighbours are whatever was emitted alongside - for an unfolded one the two are
2999
+ // the same thing.
3000
+ for (const lowerRound of lowerRounds) {
3001
+ if (assignedRoundIds.has(lowerRound.id))
3002
+ continue;
3003
+ const side = lowerRound.mirrorRoundType;
3004
+ const depth = lowerRound.logicalIndex;
3005
+ const prevLowerRound = roundAtDepth({ byDepth: lowerRoundsByDepth, depth: depth - 1, side });
3006
+ const nextLowerRound = roundAtDepth({ byDepth: lowerRoundsByDepth, depth: depth + 1, side });
3007
+ if (nextLowerRound) {
3008
+ if (!prevLowerRound) {
3009
+ relations.push(createNothingToOneRelation({ currentRound: lowerRound, nextRound: nextLowerRound }));
3010
+ }
3011
+ else {
3012
+ relations.push(createOneToOneRelation({
3013
+ currentRound: lowerRound,
3014
+ previousRound: prevLowerRound,
3015
+ nextRound: nextLowerRound,
3016
+ rootRound: firstLowerRound,
3017
+ }));
3018
+ }
3019
+ }
3020
+ else if (prevLowerRound) {
3021
+ // Truncated bracket: the last lower round has no final round to feed into, so it terminates here.
3022
+ // In a complete bracket this round is wired up by handleFinalRound and already in assignedRoundIds.
3023
+ relations.push(createOneToNothingRelation({
3024
+ currentRound: lowerRound,
3025
+ previousRound: prevLowerRound,
3026
+ rootRound: firstLowerRound,
3027
+ }));
3028
+ }
3029
+ }
3030
+ }
3031
+ return relations;
3032
+ };
3033
+
3034
+ const createBracket = (source, options) => {
3035
+ if (!source.rounds.length)
3036
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.SOURCE_EMPTY, 'The bracket source has no rounds to render');
3037
+ const bracketNewBase = createBracketBase(source, options);
3038
+ const rounds = new BracketMap();
3039
+ const roundsByType = new BracketMap();
3040
+ for (const roundBase of bracketNewBase.rounds.values()) {
3041
+ const relation = { type: 'none' };
3042
+ const newRound = { ...roundBase, matches: new BracketMap(), relation };
3043
+ relation.currentRound = newRound;
3044
+ rounds.set(roundBase.id, newRound);
3045
+ if (!roundsByType.has(roundBase.type)) {
3046
+ roundsByType.set(roundBase.type, new BracketMap());
3047
+ }
3048
+ roundsByType.getOrThrow(roundBase.type).set(roundBase.id, newRound);
3049
+ }
3050
+ const participants = new BracketMap();
3051
+ for (const participantBase of bracketNewBase.participants.values()) {
3052
+ participants.set(participantBase.id, {
3053
+ ...participantBase,
3054
+ matches: new BracketMap(),
3055
+ });
3056
+ }
3057
+ const matches = new BracketMap();
3058
+ for (const matchBase of bracketNewBase.matches.values()) {
3059
+ const round = rounds.getOrThrow(matchBase.roundId);
3060
+ const homeParticipant = matchBase.home
3061
+ ? { ...matchBase.home, matches: new BracketMap() }
3062
+ : null;
3063
+ const awayParticipant = matchBase.away
3064
+ ? { ...matchBase.away, matches: new BracketMap() }
3065
+ : null;
3066
+ const relation = { type: 'none', currentRound: round };
3067
+ const newMatch = {
3068
+ ...matchBase,
3069
+ home: homeParticipant,
3070
+ away: awayParticipant,
3071
+ winner: null,
3072
+ round,
3073
+ relation,
3074
+ };
3075
+ relation.currentMatch = newMatch;
3076
+ if (matchBase.winner) {
3077
+ const winnerParticipant = homeParticipant?.id === matchBase.winner.id ? homeParticipant : awayParticipant;
3078
+ if (!winnerParticipant)
3079
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.WINNER_NOT_FOUND, `Winner participant with id ${matchBase.winner.id} not found in match base`);
3080
+ newMatch.winner = winnerParticipant;
3081
+ }
3082
+ matches.set(matchBase.id, newMatch);
3083
+ round.matches.set(matchBase.id, newMatch);
3084
+ }
3085
+ const newBracket = {
3086
+ matches,
3087
+ participants,
3088
+ rounds,
3089
+ roundsByType,
3090
+ mode: bracketNewBase.mode,
3091
+ };
3092
+ const roundRelations = generateRoundRelationsNew(newBracket);
3093
+ for (const roundRelation of roundRelations) {
3094
+ roundRelation.currentRound.relation = roundRelation;
3095
+ }
3096
+ const matchRelations = generateMatchRelationsNew(newBracket, {
3097
+ source,
3098
+ previousMatchIds: options.previousMatchIds,
3099
+ });
3100
+ for (const matchRelation of matchRelations) {
3101
+ matchRelation.currentMatch.relation = matchRelation;
3102
+ }
3103
+ // After the relations, never before: a participant's view of a match is a copy of it, so building it
3104
+ // any earlier would freeze the placeholder relation into every copy.
3105
+ for (const match of matches.values()) {
3106
+ if (match.home) {
3107
+ participants.getOrThrow(match.home.id).matches.set(match.id, { ...match, me: match.home, opponent: match.away });
3108
+ }
3109
+ if (match.away) {
3110
+ participants.getOrThrow(match.away.id).matches.set(match.id, { ...match, me: match.away, opponent: match.home });
3111
+ }
3112
+ }
3113
+ for (const participant of participants.values()) {
3114
+ for (const match of participant.matches.values()) {
3115
+ if (match.home?.id === participant.id)
3116
+ match.home.matches = participant.matches;
3117
+ if (match.away?.id === participant.id)
3118
+ match.away.matches = participant.matches;
3119
+ if (match.winner?.id === participant.id)
3120
+ match.winner.matches = participant.matches;
3121
+ }
3122
+ }
3123
+ return newBracket;
3124
+ };
3125
+
3126
+ const isBracketSlotPredictable = (source) => source?.kind === 'match-outcome' || source?.kind === 'standing-rank' || source?.kind === 'seed';
3127
+ const sourceFor = (match, side) => side === 'home' ? match.homeSource : match.awaySource;
3128
+ const participantIdFor = (match, side) => match[side]?.id ?? null;
3129
+ const resolveMatchOutcome = (options) => {
3130
+ const { walk, source } = options;
3131
+ if (!source.matchId || !source.role)
3132
+ return null;
3133
+ const feeder = walk.bracket.matches.get(source.matchId);
3134
+ if (!feeder)
3135
+ return null;
3136
+ const homeSource = sourceFor(feeder, 'home');
3137
+ const awaySource = sourceFor(feeder, 'away');
3138
+ const home = resolveSlot({ walk, match: feeder, side: 'home' });
3139
+ const away = resolveSlot({ walk, match: feeder, side: 'away' });
3140
+ if (homeSource?.kind === 'bye' && awaySource?.kind !== 'bye') {
3141
+ return source.role === 'winner' ? away : null;
3142
+ }
3143
+ if (awaySource?.kind === 'bye' && homeSource?.kind !== 'bye') {
3144
+ return source.role === 'winner' ? home : null;
3145
+ }
3146
+ if (!home || !away) {
3147
+ return walk.keepPickWhileFeederSideIsOpen && source.role === 'winner' ? walk.picks.matchWinner(feeder.id) : null;
3148
+ }
3149
+ const pickedWinner = walk.picks.matchWinner(feeder.id);
3150
+ if (pickedWinner !== home && pickedWinner !== away)
3151
+ return null;
3152
+ return source.role === 'winner' ? pickedWinner : pickedWinner === home ? away : home;
3153
+ };
3154
+ const resolveSource = (options) => {
3155
+ const { walk, match, side, source } = options;
3156
+ switch (source.kind) {
3157
+ case 'match-outcome':
3158
+ return resolveMatchOutcome({ walk, source });
3159
+ case 'standing-rank':
3160
+ return source.standingId && source.rank !== null
3161
+ ? walk.picks.standingRank({ standingId: source.standingId, rank: source.rank })
3162
+ : null;
3163
+ case 'seed':
3164
+ case 'swiss-bucket':
3165
+ case 'external':
3166
+ return participantIdFor(match, side);
3167
+ case 'bye':
3168
+ return null;
3169
+ }
3170
+ };
3171
+ const resolveSlotOccupant = (options) => {
3172
+ const { walk, match, side } = options;
3173
+ const real = participantIdFor(match, side);
3174
+ if (real !== null && walk.realParticipantOutranksPick(match))
3175
+ return real;
3176
+ const source = sourceFor(match, side);
3177
+ return source ? resolveSource({ walk, match, side, source }) : real;
3178
+ };
3179
+ const resolveSlot = (options) => {
3180
+ const { walk, match, side } = options;
3181
+ const visitKey = `${match.id}:${side}`;
3182
+ const openDepth = walk.visitDepths.get(visitKey);
3183
+ if (openDepth !== undefined) {
3184
+ walk.shallowestCycleHit = Math.min(walk.shallowestCycleHit, openDepth);
3185
+ return null;
3186
+ }
3187
+ const resolved = walk.resolved.get(visitKey);
3188
+ if (resolved !== undefined)
3189
+ return resolved;
3190
+ const depth = walk.visitDepths.size;
3191
+ const shallowestCycleHitBefore = walk.shallowestCycleHit;
3192
+ walk.visitDepths.set(visitKey, depth);
3193
+ walk.shallowestCycleHit = Infinity;
3194
+ try {
3195
+ const result = resolveSlotOccupant({ walk, match, side });
3196
+ // A result whose cycle guard only fired on this slot or below it is a function of the slot alone.
3197
+ // One that hit a slot above it depends on the path that reached it, so caching it would answer a
3198
+ // later path with a cycle's `null`.
3199
+ if (walk.shallowestCycleHit >= depth)
3200
+ walk.resolved.set(visitKey, result);
3201
+ return result;
3202
+ }
3203
+ finally {
3204
+ walk.visitDepths.delete(visitKey);
3205
+ walk.shallowestCycleHit = Math.min(walk.shallowestCycleHit, shallowestCycleHitBefore);
3206
+ }
3207
+ };
3208
+ const createResolveWalk = (options) => ({
3209
+ bracket: options.bracket,
3210
+ picks: options.picks,
3211
+ realParticipantOutranksPick: options.realParticipantOutranksPick ?? (() => false),
3212
+ keepPickWhileFeederSideIsOpen: options.keepPickWhileFeederSideIsOpen ?? false,
3213
+ visitDepths: new Map(),
3214
+ resolved: new Map(),
3215
+ shallowestCycleHit: Infinity,
3216
+ });
3217
+ /**
3218
+ * @internal A {@link resolveBracketSlot} that shares one memo across its calls. Valid only while the
3219
+ * picks it was created with do not change.
3220
+ */
3221
+ const createBracketSlotResolver = (options) => {
3222
+ const walk = createResolveWalk(options);
3223
+ return (matchId, side) => {
3224
+ const match = options.bracket.matches.get(matchId);
3225
+ return match ? resolveSlot({ walk, match, side }) : null;
3226
+ };
3227
+ };
3228
+ /** Who the viewer's own picks put in a slot, or `null` while their picks do not reach it. */
3229
+ const resolveBracketSlot = (options) => createBracketSlotResolver(options)(options.matchId, options.side);
3230
+
3231
+ // Grouped by type and depth rather than by round id: a mirrored layout splits one round into two
3232
+ // half-rounds to draw either side of the fold, and a pick has to be able to travel across it. Ordered
3233
+ // topologically over the declared provenance, because a round is settled against the ones feeding it.
3234
+ const roundsOf = (bracket) => {
3235
+ const matchesByKey = new Map();
3236
+ const keyByMatchId = new Map();
3237
+ for (const round of bracket.rounds.values()) {
3238
+ const key = `${round.type}:${round.logicalIndex}`;
3239
+ const matches = matchesByKey.get(key) ?? [];
3240
+ for (const match of round.matches.values()) {
3241
+ matches.push(match);
3242
+ keyByMatchId.set(match.id, key);
3243
+ }
3244
+ matchesByKey.set(key, matches);
3245
+ }
3246
+ const unsettledFeedersByKey = new Map();
3247
+ for (const [key, matches] of matchesByKey) {
3248
+ const feeders = new Set();
3249
+ for (const match of matches) {
3250
+ for (const source of [match.homeSource, match.awaySource]) {
3251
+ if (source?.kind !== 'match-outcome' || !source.matchId)
3252
+ continue;
3253
+ const feederKey = keyByMatchId.get(source.matchId);
3254
+ if (feederKey !== undefined && feederKey !== key)
3255
+ feeders.add(feederKey);
3256
+ }
3257
+ }
3258
+ unsettledFeedersByKey.set(key, feeders);
3259
+ }
3260
+ const ordered = [];
3261
+ while (unsettledFeedersByKey.size) {
3262
+ const keys = [...unsettledFeedersByKey.keys()];
3263
+ // A malformed graph can leave a cycle in which nothing is ready, so fall back to the first
3264
+ // round left rather than looping forever.
3265
+ const key = keys.find((candidate) => !unsettledFeedersByKey.get(candidate)?.size) ?? keys[0];
3266
+ if (key === undefined)
3267
+ break;
3268
+ ordered.push(matchesByKey.get(key) ?? []);
3269
+ unsettledFeedersByKey.delete(key);
3270
+ for (const feeders of unsettledFeedersByKey.values())
3271
+ feeders.delete(key);
3272
+ }
3273
+ return ordered;
3274
+ };
3275
+ /**
3276
+ * Where every pick stands once each one has followed its participant into whichever match of its own
3277
+ * round that participant now plays. A pick nothing in the round can honour comes back stranded.
3278
+ */
3279
+ const migrateBracketPicks = (options) => {
3280
+ const { bracket, pickAsMade } = options;
3281
+ const locked = options.lockedMatchIds ?? new Set();
3282
+ const pickByMatchId = {};
3283
+ const movedFromByMatchId = {};
3284
+ const strandedByMatchId = {};
3285
+ const settled = {
3286
+ matchWinner: (matchId) => pickByMatchId[matchId] ?? null,
3287
+ standingRank: options.standingRank ?? (() => null),
3288
+ };
3289
+ for (const round of roundsOf(bracket)) {
3290
+ const sidesByMatchId = new Map();
3291
+ const travelling = [];
3292
+ const strandedHere = new Set();
3293
+ const sidesOf = (matchId) => sidesByMatchId.get(matchId) ?? [null, null];
3294
+ const resolve = createBracketSlotResolver({
3295
+ bracket,
3296
+ picks: settled,
3297
+ realParticipantOutranksPick: options.realParticipantOutranksPick,
3298
+ keepPickWhileFeederSideIsOpen: options.keepPickWhileFeederSideIsOpen,
3299
+ });
3300
+ for (const match of round) {
3301
+ sidesByMatchId.set(match.id, [resolve(match.id, 'home'), resolve(match.id, 'away')]);
3302
+ }
3303
+ for (const match of round) {
3304
+ const pick = pickAsMade(match.id);
3305
+ if (!pick)
3306
+ continue;
3307
+ const [home, away] = sidesOf(match.id);
3308
+ if (pick === home || pick === away) {
3309
+ pickByMatchId[match.id] = pick;
3310
+ continue;
3311
+ }
3312
+ if (locked.has(match.id)) {
3313
+ pickByMatchId[match.id] = pick;
3314
+ strandedByMatchId[match.id] = pick;
3315
+ continue;
3316
+ }
3317
+ travelling.push({ matchId: match.id, pick });
3318
+ }
3319
+ for (const { matchId, pick } of travelling) {
3320
+ const target = round.find((candidate) => {
3321
+ if (candidate.id === matchId || locked.has(candidate.id) || pickByMatchId[candidate.id])
3322
+ return false;
3323
+ if (strandedHere.has(candidate.id))
3324
+ return false;
3325
+ const [home, away] = sidesOf(candidate.id);
3326
+ return pick === home || pick === away;
3327
+ });
3328
+ if (!target) {
3329
+ strandedByMatchId[matchId] = pick;
3330
+ strandedHere.add(matchId);
3331
+ continue;
3332
+ }
3333
+ pickByMatchId[target.id] = pick;
3334
+ movedFromByMatchId[target.id] = matchId;
3335
+ }
3336
+ // After the travelling, not during it: a match that carries the stranded note holds no selection
3337
+ // at all, even where a pick reached it in the meantime.
3338
+ for (const matchId of strandedHere)
3339
+ delete pickByMatchId[matchId];
3340
+ }
3341
+ return { pickByMatchId, movedFromByMatchId, strandedByMatchId };
3342
+ };
3343
+
3344
+ const getSwissGroupBoxPadding = (options) => options.swissGroupPadding + options.swissGroupBorderWidth;
3345
+ const calculateSwissColumnMetrics = (groupMap, options) => {
3346
+ const includeHeaders = options.roundHeaderHeight > 0;
3347
+ const boxPadding = getSwissGroupBoxPadding(options);
3348
+ let naturalHeight = 0;
3349
+ let stretchableGapCount = 0;
3350
+ for (const [groupIndex, group] of Array.from(groupMap.groups.values()).entries()) {
3351
+ if (groupIndex !== 0) {
3352
+ // The gap section between two groups
3353
+ naturalHeight += options.rowRoundGap;
3354
+ stretchableGapCount++;
3355
+ }
3356
+ // The round header gap is not stretchable: the first match should always sit directly
3357
+ // below it, no matter how much a column gets stretched.
3358
+ if (includeHeaders) {
3359
+ naturalHeight += options.roundHeaderHeight + options.roundHeaderGap;
3360
+ }
3361
+ const matchGapCount = Math.max(0, group.matches.size - 1);
3362
+ naturalHeight += boxPadding * 2 + group.matches.size * options.matchHeight + matchGapCount * options.rowGap;
3363
+ stretchableGapCount += matchGapCount;
3364
+ }
3365
+ return { naturalHeight, stretchableGapCount };
3366
+ };
3367
+ // The naturally tallest master column (the round with the most groups) keeps the original
3368
+ // gaps (rowGap, rowRoundGap, roundHeaderGap) and defines the total bracket height.
3369
+ // The master cols before it are stretched to that height by distributing the missing
3370
+ // height evenly across all of their gap elements.
3371
+ // The master cols after it keep the original gaps, resulting in a top alignment. They get
3372
+ // one more gap element at the end of the column to fill the remaining space if any.
3373
+ const createSwissGrid = (bracketData, options, components) => {
3374
+ const swissGroupMaps = generateBracketRoundSwissGroupMaps(bracketData);
3375
+ if (!swissGroupMaps)
3376
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.SWISS_GROUPING_FAILED, 'Unable to generate Swiss groups for the provided bracket data');
3377
+ const grid = createBracketGrid({ spanElementWidth: options.columnWidth });
3378
+ const groupMaps = Array.from(swissGroupMaps.values());
3379
+ const columnMetrics = groupMaps.map((groupMap) => calculateSwissColumnMetrics(groupMap, options));
3380
+ const totalHeight = Math.max(...columnMetrics.map((metrics) => metrics.naturalHeight));
3381
+ const longestColumnIndex = columnMetrics.findIndex((metrics) => metrics.naturalHeight === totalHeight);
3382
+ for (const [groupMapIndex, groupMap] of groupMaps.entries()) {
3383
+ const isLastGroupMap = groupMapIndex === swissGroupMaps.size - 1;
3384
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3385
+ const metrics = columnMetrics[groupMapIndex];
3386
+ const heightToFill = totalHeight - metrics.naturalHeight;
3387
+ const isStretched = groupMapIndex < longestColumnIndex && metrics.stretchableGapCount > 0;
3388
+ const gapStretch = isStretched ? heightToFill / metrics.stretchableGapCount : 0;
3389
+ const rowGap = options.rowGap + gapStretch;
3390
+ const rowRoundGap = options.rowRoundGap + gapStretch;
3391
+ // The border and group padding are part of the column width (the match elements
3392
+ // shrink by them), so the column footprint stays at columnWidth and the group borders
3393
+ // drawn around the matches don't overflow the bracket container. The padding lives on
3394
+ // the match list sections, the header and gap sections stay unpadded and span the
3395
+ // whole column width.
3396
+ const boxPadding = getSwissGroupBoxPadding(options);
3397
+ const { masterColumn, pushSection } = createBracketMasterColumn({
3398
+ columnWidth: options.columnWidth,
3399
+ padding: {
3400
+ bottom: 0,
3401
+ left: 0,
3402
+ right: 0,
3403
+ top: 0,
3404
+ },
3405
+ });
3406
+ for (const [groupIndex, group] of Array.from(groupMap.groups.values()).entries()) {
3407
+ const isLastGroup = groupIndex === groupMap.groups.size - 1;
3408
+ if (options.roundHeaderHeight > 0) {
3409
+ const firstMatchRound = group.matches.first()?.round;
3410
+ if (!firstMatchRound)
3411
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.SWISS_GROUP_EMPTY, 'No rounds found in Swiss group');
3412
+ const headerColumnSection = createBracketMasterColumnSection({
3413
+ type: 'round',
3414
+ });
3415
+ const headerSubColumn = createBracketSubColumn({
3416
+ span: {
3417
+ isStart: true,
3418
+ isEnd: true,
3419
+ },
3420
+ });
3421
+ const headerElement = createBracketElement({
3422
+ type: 'header',
3423
+ area: `h${group.id}`,
3424
+ partHeights: [options.roundHeaderHeight],
3425
+ elementHeight: options.roundHeaderHeight,
3426
+ component: components.roundHeader,
3427
+ round: firstMatchRound,
3428
+ roundSwissGroup: group,
3429
+ });
3430
+ const headerGapElement = createBracketElement({
3431
+ type: 'roundHeaderGap',
3432
+ area: '.',
3433
+ partHeights: [options.roundHeaderGap],
3434
+ elementHeight: options.roundHeaderGap,
3435
+ });
3436
+ headerSubColumn.pushElement(headerElement, headerGapElement);
3437
+ headerColumnSection.pushSubColumn(headerSubColumn.subColumn);
3438
+ pushSection(headerColumnSection.masterColumnSection);
3439
+ }
3440
+ const { masterColumnSection, pushSubColumn } = createBracketMasterColumnSection({
3441
+ type: 'round',
3442
+ padding: {
3443
+ bottom: boxPadding,
3444
+ left: boxPadding,
3445
+ right: boxPadding,
3446
+ top: boxPadding,
3447
+ },
3448
+ });
3449
+ const { subColumn, pushElement } = createBracketSubColumn({
3450
+ span: {
3451
+ isStart: true,
3452
+ isEnd: true,
3453
+ },
3454
+ });
3455
+ const elementsToCreate = [];
3456
+ for (const [matchIndex, match] of Array.from(group.matches.values()).entries()) {
3457
+ const isLastMatch = matchIndex === group.matches.size - 1;
3458
+ elementsToCreate.push({
3459
+ type: 'match',
3460
+ area: `m${match.shortId}`,
3461
+ partHeights: [options.matchHeight],
3462
+ elementHeight: options.matchHeight,
3463
+ component: components.match,
3464
+ match,
3465
+ round: match.round,
3466
+ roundSwissGroup: group,
3467
+ });
3468
+ if (!isLastMatch) {
3469
+ elementsToCreate.push({
3470
+ type: 'matchGap',
3471
+ area: '.',
3472
+ partHeights: [rowGap],
3473
+ elementHeight: rowGap,
3474
+ });
3475
+ }
3476
+ }
3477
+ for (const elementData of elementsToCreate) {
3478
+ const element = createBracketElement(elementData);
3479
+ pushElement(element);
3480
+ }
3481
+ pushSubColumn(subColumn);
3482
+ pushSection(masterColumnSection);
3483
+ if (!isLastGroup) {
3484
+ const groupGapColumnSection = createBracketMasterColumnSection({
3485
+ type: 'gap',
3486
+ });
3487
+ const groupGapSubColumn = createBracketSubColumn({
3488
+ span: {
3489
+ isStart: true,
3490
+ isEnd: true,
3491
+ },
3492
+ });
3493
+ const groupGapElement = createBracketElement({
3494
+ area: '.',
3495
+ type: 'roundGap',
3496
+ elementHeight: rowRoundGap,
3497
+ partHeights: [rowRoundGap],
3498
+ });
3499
+ groupGapSubColumn.pushElement(groupGapElement);
3500
+ groupGapColumnSection.pushSubColumn(groupGapSubColumn.subColumn);
3501
+ pushSection(groupGapColumnSection.masterColumnSection);
3502
+ }
3503
+ }
3504
+ // Top aligned columns end above the total height, so we fill the remaining space with
3505
+ // one last gap element.
3506
+ const fillerHeight = isStretched ? 0 : heightToFill;
3507
+ if (fillerHeight > 0) {
3508
+ const fillerColumnSection = createBracketMasterColumnSection({
3509
+ type: 'gap',
3510
+ });
3511
+ const fillerSubColumn = createBracketSubColumn({
3512
+ span: {
3513
+ isStart: true,
3514
+ isEnd: true,
3515
+ },
3516
+ });
3517
+ const fillerElement = createBracketElement({
3518
+ area: '.',
3519
+ type: 'colGap',
3520
+ elementHeight: fillerHeight,
3521
+ partHeights: [fillerHeight],
3522
+ });
3523
+ fillerSubColumn.pushElement(fillerElement);
3524
+ fillerColumnSection.pushSubColumn(fillerSubColumn.subColumn);
3525
+ pushSection(fillerColumnSection.masterColumnSection);
3526
+ }
3527
+ grid.pushMasterColumn(masterColumn);
3528
+ if (!isLastGroupMap) {
3529
+ grid.pushMasterColumn(createBracketGapMasterColumn({
3530
+ existingMasterColumns: grid.grid.masterColumns,
3531
+ columnGap: options.columnGap,
3532
+ }));
3533
+ }
3534
+ }
3535
+ grid.calculateDimensions();
3536
+ const finalizedGrid = finalizeBracketGrid(grid);
3537
+ return {
3538
+ raw: grid,
3539
+ columns: finalizedGrid.columns,
3540
+ matchElementMap: finalizedGrid.elementMap,
3541
+ };
3542
+ };
3543
+
3544
+ /** Returns the width needed to render a computed bracket grid without horizontal clipping. */
3545
+ const bracketGridNaturalWidth = (grid) => grid.raw.grid.dimensions.width;
3546
+
3547
+ const LAYOUT_FACTORY_SUGGESTION = {
3548
+ 'single-elimination': 'singleEliminationBracketLayout()',
3549
+ 'double-elimination': 'doubleEliminationBracketLayout()',
3550
+ 'swiss-with-elimination': 'swissBracketLayout()',
3551
+ };
3552
+ /** Returns the first registered layout matching the tournament mode. */
3553
+ const resolveBracketLayout = (layouts, mode) => {
3554
+ const layout = layouts?.find((candidate) => candidate.mode === mode);
3555
+ if (!layout) {
3556
+ throw new BracketRuntimeError(BRACKET_ERROR_CODES.LAYOUT_NOT_REGISTERED, `No bracket layout registered for mode "${mode}". Add ${LAYOUT_FACTORY_SUGGESTION[mode]} to provideBracketConfig({ layouts: [...] }) or to the layouts input.`);
3557
+ }
3558
+ return layout;
3559
+ };
3560
+
3561
+ /** How a group-table prediction scored on one position, once the real order is in. */
3562
+ const STANDING_PICK_OUTCOME = {
3563
+ /** The participant finished on exactly the predicted position. */
3564
+ EXACT: 'exact',
3565
+ /** They finished on the predicted side of the advancing line, on another position. */
3566
+ PARTIAL: 'partial',
3567
+ /** They finished on the other side of the advancing line. */
3568
+ WRONG: 'wrong',
3569
+ };
3570
+ /**
3571
+ * How the participant predicted on `predictedPosition` scored, given where they really finished.
3572
+ *
3573
+ * `advancingCount` is how many positions advance, so a position is on the advancing side while it is
3574
+ * `<= advancingCount`. Ending up on the other side of that line is `WRONG` whatever the distance; the
3575
+ * predicted side of it is `EXACT` on the predicted position and `PARTIAL` on any other.
3576
+ *
3577
+ * `EXACT` is awarded below the line as well. An app whose API stores only the advancing positions holds no
3578
+ * prediction for the rest - the order there is whatever its UI happened to draw - and should narrow the
3579
+ * result to `PARTIAL`/`WRONG` itself for those positions.
3580
+ *
3581
+ * Returns the outcome and no points: what an outcome is worth is a rule of the competition, and
3582
+ * `POINTS[outcome]` is a lookup the app already owns.
3583
+ */
3584
+ const standingPickOutcome = (options) => {
3585
+ const { predictedPosition, actualPosition, advancingCount } = options;
3586
+ const predictedToAdvance = predictedPosition <= advancingCount;
3587
+ if (predictedToAdvance !== actualPosition <= advancingCount)
3588
+ return STANDING_PICK_OUTCOME.WRONG;
3589
+ if (predictedPosition === actualPosition)
3590
+ return STANDING_PICK_OUTCOME.EXACT;
3591
+ return STANDING_PICK_OUTCOME.PARTIAL;
3592
+ };
3593
+
3594
+ /**
3595
+ * The order to show a group table in before anyone moves a row: every stored pick takes the position it was
3596
+ * stored on, and the participants without one follow in the order the backend listed them, filling the gaps.
3597
+ *
3598
+ * A pick the field cannot hold is dropped rather than drawn - a position that is not an integer in
3599
+ * `1..participantIds.length`, an id that is not in the field, a second pick for a position or for a
3600
+ * participant already placed (the lowest position keeps it). The result is therefore always a permutation
3601
+ * of `participantIds`.
3602
+ */
3603
+ const standingPickStartOrder = (options) => {
3604
+ const { participantIds, picks } = options;
3605
+ const field = new Set(participantIds);
3606
+ const byPosition = new Map();
3607
+ const placed = new Set();
3608
+ for (const pick of [...picks].sort((left, right) => left.position - right.position)) {
3609
+ const holds = Number.isInteger(pick.position) &&
3610
+ pick.position >= 1 &&
3611
+ pick.position <= participantIds.length &&
3612
+ field.has(pick.participantId) &&
3613
+ !placed.has(pick.participantId) &&
3614
+ !byPosition.has(pick.position);
3615
+ if (!holds)
3616
+ continue;
3617
+ byPosition.set(pick.position, pick.participantId);
3618
+ placed.add(pick.participantId);
3619
+ }
3620
+ const rest = participantIds.filter((id) => !placed.has(id));
3621
+ const order = [];
3622
+ for (let position = 1; position <= participantIds.length; position++) {
3623
+ const id = byPosition.get(position) ?? rest.shift();
3624
+ if (id !== undefined)
3625
+ order.push(id);
3626
+ }
3627
+ return order;
3628
+ };
3629
+
3630
+ /**
3631
+ * Generated bundle index. Do not edit.
3632
+ */
3633
+
3634
+ export { BRACKET_DATA_LAYOUT, BRACKET_ERROR_CODES, BRACKET_ROUND_MIRROR_TYPE, BRACKET_SWISS_GROUP_COLOR_TYPE, BracketMap, COMMON_BRACKET_ROUND_TYPE, DOUBLE_ELIMINATION_BRACKET_ROUND_TYPE, GROUP_BRACKET_ROUND_TYPE, SINGLE_ELIMINATION_BRACKET_ROUND_TYPE, STANDING_PICK_OUTCOME, SWISS_ADVANCE_WINS, SWISS_BRACKET_ROUND_TYPE, SWISS_ELIMINATE_LOSSES, TERMINAL_ROUND_SORT_PRIORITY, TOURNAMENT_MODE, bracketGridNaturalWidth, calculateColumnSplitFactor, calculateLowerRoundIndex, calculateUpperLowerRatio, calculateUpperRoundIndex, createBracket, createBracketBase, createBracketContinueMasterColumn, createBracketElement, createBracketElementPart, createBracketGapMasterColumn, createBracketGrid, createBracketMasterColumn, createBracketMasterColumnSection, createBracketSlotResolver, createBracketSubColumn, createDoubleEliminationGrid, createFoldedThirdPlaceSection, createMatchesMapBase, createNewMatchParticipantBase, createParticipantsMapBase, createRoundBracketSubColumnRelativeToFirstRound, createRoundsMapBase, createSingleEliminationGrid, createStackedDoubleEliminationGrid, createSwissGrid, curvePath, drawMan, drawSwissMan, finalizeBracketGrid, generateBracketRoundSwissGroupMaps, generateMatchPosition, generateMatchRelationPositions, generateMatchRelationsNew, generateRoundRelationsNew, getAvailableSwissGroupsForRound, getBracketContinueMatches, getSwissGroupColorType, gutterPath, isBracketContinueMatch, isBracketSlotPredictable, linePath, migrateBracketPicks, path, resolveBracketGridRowSpan, resolveBracketLayout, resolveBracketSlot, standingPickOutcome, standingPickStartOrder, verticalPath };
3635
+ //# sourceMappingURL=ethlete-bracket.mjs.map