More compact elim bracket when a lot of first round byes

This commit is contained in:
Kalle
2026-06-12 17:32:03 +03:00
parent facb0e358e
commit c6ca84a787
2 changed files with 213 additions and 3 deletions

View File

@@ -219,6 +219,120 @@ function createSingleEliminationData(): TournamentManagerDataSet {
};
}
function createByeHeavySingleEliminationData(): TournamentManagerDataSet {
return {
stage: [
{
id: 1,
name: "Main Bracket",
number: 1,
type: "single_elimination",
tournament_id: 1,
settings: { size: 8 },
},
],
group: [{ id: 1, number: 1, stage_id: 1 }],
round: [
{
id: 1,
group_id: 1,
number: 1,
stage_id: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
number: 2,
stage_id: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 1,
number: 3,
stage_id: 1,
maps: { count: 5, type: "BEST_OF", pickBan: null },
},
],
match: [
// Round 1 - 5 teams in a bracket of 8, only one match is played
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 1 },
opponent2: null,
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 3,
opponent1: { id: 4, score: 1 },
opponent2: { id: 5, score: 1 },
},
{
id: 3,
number: 3,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 2 },
opponent2: null,
},
{
id: 4,
number: 4,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 3 },
opponent2: null,
},
// Round 2 - Semis
{
id: 5,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
opponent1: { id: 1 },
opponent2: { id: null },
},
{
id: 6,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
opponent1: { id: 2 },
opponent2: { id: 3 },
},
// Round 3 - Finals
{
id: 7,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 3,
status: 4,
opponent1: { id: null },
opponent2: { id: null },
},
],
};
}
function createDoubleEliminationData(): TournamentManagerDataSet {
return {
stage: [
@@ -847,6 +961,44 @@ describe("Single Elimination Bracket", () => {
await expect.element(screen.getByText("Finals")).toBeVisible();
});
test("compacts first round when fewer than half of its matches are played", async () => {
const data = createByeHeavySingleEliminationData();
const bracket = createMockBracket("single_elimination", data);
const screen = await renderWithRouter(
<EliminationBracketSide bracket={bracket} type="single" isExpanded />,
);
// one slot per round 2 match instead of one per potential round 1 match
const firstRoundMatchWrappers = screen.container.querySelectorAll(
`[data-round-id="1"] .${styles.matchWrapper}`,
);
expect(firstRoundMatchWrappers.length).toBe(2);
// played match connects to its destination with a straight line
const straightLines = screen.container.querySelectorAll(
`[data-round-id="1"] .${styles.matchLineStraight}`,
);
expect(straightLines.length).toBeGreaterThan(0);
await expect.element(screen.getByText("Team Delta")).toBeVisible();
await expect.element(screen.getByText("Team Epsilon")).toBeVisible();
});
test("does not compact first round when at least half of its matches are played", async () => {
const data = createSingleEliminationData();
const bracket = createMockBracket("single_elimination", data);
const screen = await renderWithRouter(
<EliminationBracketSide bracket={bracket} type="single" isExpanded />,
);
const firstRoundMatchWrappers = screen.container.querySelectorAll(
`[data-round-id="1"] .${styles.matchWrapper}`,
);
expect(firstRoundMatchWrappers.length).toBe(4);
});
test("shows early round with ongoing match even when isExpanded is false", async () => {
const data = createLargeSingleEliminationData({ ongoingRoundIdx: 0 });
const bracket = createMockBracket("single_elimination", data);

View File

@@ -1,4 +1,5 @@
import clsx from "clsx";
import * as R from "remeda";
import { TOURNAMENT } from "../../../tournament/tournament-constants";
import type { Bracket as BracketType } from "../../core/Bracket";
import { getRounds } from "../../core/rounds";
@@ -52,6 +53,16 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
(match) => match.round_id === firstVisibleRound?.id,
).length;
const compactedFirstRoundId = resolveCompactedFirstRoundId({
rounds,
firstVisibleRoundId: firstVisibleRound?.id,
bracketData: props.bracket.data,
});
const baseRoundMatchCount =
compactedFirstRoundId !== null
? firstVisibleRoundMatchCount / 2
: firstVisibleRoundMatchCount;
let atLeastOneColumnHidden = false;
return (
<div
@@ -61,9 +72,17 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
{rounds.flatMap((round, roundIdx) => {
const bestOf = round.maps?.count;
const matches = props.bracket.data.match.filter(
const allRoundMatches = props.bracket.data.match.filter(
(match) => match.round_id === round.id,
);
const matches =
round.id === compactedFirstRoundId
? R.chunk(allRoundMatches, 2).map(
(pair) =>
pair.find((match) => match.opponent1 && match.opponent2) ??
pair[0],
)
: allRoundMatches;
const isLastRound = roundIdx === rounds.length - 1;
const nextRound = rounds[roundIdx + 1];
@@ -103,6 +122,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
className={clsx(styles.elimRoundMatchesContainer, {
[styles.elimRoundMatchesContainerTopBye]:
!atLeastOneColumnHidden &&
compactedFirstRoundId === null &&
(props.type === "winners" || props.type === "single") &&
(!props.bracket.data.match[0].opponent1 ||
!props.bracket.data.match[0].opponent2),
@@ -128,8 +148,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
if (matches.length <= 1) return undefined;
if (nextRoundMatchCount === matches.length) return undefined;
const spreadFactor =
firstVisibleRoundMatchCount / matches.length;
const spreadFactor = baseRoundMatchCount / matches.length;
return GAP / 2 + (spreadFactor - 1) * (MATCH_SPACING / 2);
})();
@@ -181,3 +200,42 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
</div>
);
}
/**
* Resolves whether the first round should be rendered compacted, meaning one
* slot per second round match instead of two. This is possible when fewer
* than half of the potential first round matches are played, as then each
* second round match has at most one first round feeder and the played
* matches can be laid out right next to their destination with a straight
* connector, halving the bracket's height.
*/
function resolveCompactedFirstRoundId(args: {
rounds: ReturnType<typeof getRounds>;
firstVisibleRoundId?: number;
bracketData: BracketType["data"];
}): number | null {
const [firstRound, secondRound] = args.rounds;
if (!firstRound || !secondRound) return null;
if (firstRound.id !== args.firstVisibleRoundId) return null;
const firstRoundMatches = args.bracketData.match.filter(
(match) => match.round_id === firstRound.id,
);
const secondRoundMatchCount = args.bracketData.match.filter(
(match) => match.round_id === secondRound.id,
).length;
if (firstRoundMatches.length !== secondRoundMatchCount * 2) return null;
const playedMatchCount = firstRoundMatches.filter(
(match) => match.opponent1 && match.opponent2,
).length;
if (playedMatchCount >= firstRoundMatches.length / 2) return null;
const everyPairHasAtMostOnePlayedMatch = R.chunk(firstRoundMatches, 2).every(
(pair) =>
pair.filter((match) => match.opponent1 && match.opponent2).length <= 1,
);
if (!everyPairHasAtMostOnePlayedMatch) return null;
return firstRound.id;
}