@ethlete/bracket 1.0.0-next.1

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