Add elimination bracket lines

This commit is contained in:
Kalle 2026-01-06 15:39:54 +02:00
parent 863bf78140
commit 3ff60df9ea
3 changed files with 190 additions and 27 deletions

View File

@ -12,9 +12,18 @@ interface EliminationBracketSideProps {
isExpanded?: boolean;
}
// these values must match --match-height and gap in bracket.module.css
const MATCH_HEIGHT = 55;
const GAP = 28;
const MATCH_SPACING = MATCH_HEIGHT + GAP;
export function EliminationBracketSide(props: EliminationBracketSideProps) {
const rounds = getRounds({ ...props, bracketData: props.bracket.data });
const firstRoundMatchCount = props.bracket.data.match.filter(
(match) => match.round_id === rounds[0]?.id,
).length;
let atLeastOneColumnHidden = false;
return (
<div
@ -28,6 +37,14 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
(match) => match.round_id === round.id,
);
const isLastRound = roundIdx === rounds.length - 1;
const nextRound = rounds[roundIdx + 1];
const nextRoundMatchCount = nextRound
? props.bracket.data.match.filter(
(match) => match.round_id === nextRound.id,
).length
: 0;
const someMatchOngoing = matches.some(
(match) =>
match.opponent1 &&
@ -68,28 +85,49 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
!props.bracket.data.match[0].opponent2),
})}
>
{matches.map((match) => (
<Match
key={match.id}
match={match}
roundNumber={round.number}
isPreview={props.bracket.preview}
showSimulation={
round.name !== TOURNAMENT.ROUND_NAMES.BRACKET_RESET
}
bracket={props.bracket}
type={
round.name === TOURNAMENT.ROUND_NAMES.GRAND_FINALS ||
round.name === TOURNAMENT.ROUND_NAMES.BRACKET_RESET
? "grands"
: props.type === "winners"
? "winners"
: props.type === "losers"
? "losers"
: undefined
}
/>
))}
{matches.map((match, matchIdx) => {
const lineType = (() => {
if (isLastRound) return "none" as const;
if (nextRoundMatchCount === matches.length)
return "straight" as const;
return matchIdx % 2 === 0
? ("curve-down" as const)
: ("curve-up" as const);
})();
const verticalExtend = (() => {
if (matches.length <= 1) return undefined;
if (nextRoundMatchCount === matches.length) return undefined;
const spreadFactor = firstRoundMatchCount / matches.length;
return GAP / 2 + (spreadFactor - 1) * (MATCH_SPACING / 2);
})();
return (
<Match
key={match.id}
match={match}
roundNumber={round.number}
isPreview={props.bracket.preview}
showSimulation={
round.name !== TOURNAMENT.ROUND_NAMES.BRACKET_RESET
}
bracket={props.bracket}
type={
round.name === TOURNAMENT.ROUND_NAMES.GRAND_FINALS ||
round.name === TOURNAMENT.ROUND_NAMES.BRACKET_RESET
? "grands"
: props.type === "winners"
? "winners"
: props.type === "losers"
? "losers"
: undefined
}
lineType={lineType}
lineVerticalExtend={verticalExtend}
/>
);
})}
</div>
</div>
);

View File

@ -22,6 +22,8 @@ import parentStyles from "../../tournament-bracket.module.css";
import { matchEndedEarly } from "../../tournament-bracket-utils";
import styles from "./bracket.module.css";
type LineType = "none" | "straight" | "curve-up" | "curve-down";
interface MatchProps {
match: Unpacked<TournamentData["data"]["match"]>;
isPreview?: boolean;
@ -31,26 +33,39 @@ interface MatchProps {
showSimulation: boolean;
bracket: Bracket;
hideMatchTimer?: boolean;
lineType?: LineType;
lineVerticalExtend?: number;
}
export function Match(props: MatchProps) {
const isBye = !props.match.opponent1 || !props.match.opponent2;
if (isBye) {
return <div className={styles.matchBye} />;
return (
<div className={clsx(styles.matchBye, styles.matchWrapper)}>
<MatchLine
lineType={props.lineType}
verticalExtend={props.lineVerticalExtend}
/>
</div>
);
}
return (
<div className="relative">
<div className={styles.matchWrapper}>
<MatchHeader {...props} />
<MatchWrapper {...props}>
<MatchContent {...props}>
<MatchRow {...props} side={1} />
<div className={styles.matchSeparator} />
<MatchRow {...props} side={2} />
</MatchWrapper>
</MatchContent>
{!props.hideMatchTimer ? (
<MatchTimer match={props.match} bracket={props.bracket} />
) : null}
<MatchLine
lineType={props.lineType}
verticalExtend={props.lineVerticalExtend}
/>
</div>
);
}
@ -133,7 +148,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
);
}
function MatchWrapper({
function MatchContent({
match,
isPreview,
children,
@ -367,3 +382,36 @@ function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
</div>
);
}
interface MatchLineProps {
lineType?: LineType;
verticalExtend?: number;
}
function MatchLine({ lineType, verticalExtend }: MatchLineProps) {
if (!lineType || lineType === "none") return null;
const lineClass =
lineType === "straight"
? styles.matchLineStraight
: lineType === "curve-up"
? styles.matchLineCurveUp
: styles.matchLineCurveDown;
const style = verticalExtend
? ({
"--bracket-vertical-extend": `${verticalExtend}px`,
} as React.CSSProperties)
: undefined;
return (
<div className={clsx(styles.matchLineContainer, lineClass)} style={style}>
{lineType === "curve-down" ? (
<div className={styles.matchLineConnectorDown} style={style} />
) : null}
{lineType === "curve-up" ? (
<div className={styles.matchLineConnectorUp} style={style} />
) : null}
</div>
);
}

View File

@ -1,3 +1,4 @@
/* --match-height and gap must match constants in Elimination.tsx */
.bracket {
--match-width: 140px;
--match-height: 55px;
@ -164,6 +165,82 @@ a.match:hover {
flex-direction: column;
}
.matchWrapper {
position: relative;
width: var(--match-width);
height: var(--match-height);
}
.matchLineContainer {
position: absolute;
top: 0;
left: 100%;
width: var(--line-width);
height: 100%;
pointer-events: none;
}
.matchLineStraight::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: var(--color-border);
transform: translateY(-50%);
}
.matchLineCurveDown::before,
.matchLineCurveUp::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 50%;
height: 2px;
background: var(--color-border);
transform: translateY(-50%);
}
.matchLineCurveDown::after {
content: "";
position: absolute;
top: 50%;
left: calc(50% - 1px);
width: 2px;
height: calc(50% + var(--bracket-vertical-extend, 50px));
background: var(--color-border);
}
.matchLineCurveUp::after {
content: "";
position: absolute;
bottom: 50%;
left: calc(50% - 1px);
width: 2px;
height: calc(50% + var(--bracket-vertical-extend, 50px));
background: var(--color-border);
}
.matchLineConnectorDown {
position: absolute;
top: calc(100% + var(--bracket-vertical-extend, 50px) - 1px);
left: calc(50% - 1px);
width: calc(50% + 1px);
height: 2px;
background: var(--color-border);
}
.matchLineConnectorUp {
position: absolute;
bottom: calc(100% + var(--bracket-vertical-extend, 50px) - 1px);
left: calc(50% - 1px);
width: calc(50% + 1px);
height: 2px;
background: var(--color-border);
}
.rrPlacementsTable {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);