mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 06:08:13 -05:00
More compact elim bracket when a lot of first round byes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user