From 3ff60df9ea4fdebb3ccc88f501969066474e145f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:39:54 +0200 Subject: [PATCH] Add elimination bracket lines --- .../components/Bracket/Elimination.tsx | 82 ++++++++++++++----- .../components/Bracket/Match.tsx | 58 +++++++++++-- .../components/Bracket/bracket.module.css | 77 +++++++++++++++++ 3 files changed, 190 insertions(+), 27 deletions(-) diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index 822e50655..3b6f2b06b 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -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 (
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) => ( - - ))} + {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 ( + + ); + })}
); diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index 8aa908bbd..a72dc960b 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -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; 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
; + return ( +
+ +
+ ); } return ( -
+
- +
- + {!props.hideMatchTimer ? ( ) : null} +
); } @@ -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) {
); } + +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 ( +
+ {lineType === "curve-down" ? ( +
+ ) : null} + {lineType === "curve-up" ? ( +
+ ) : null} +
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/bracket.module.css b/app/features/tournament-bracket/components/Bracket/bracket.module.css index 4744ff409..05de14095 100644 --- a/app/features/tournament-bracket/components/Bracket/bracket.module.css +++ b/app/features/tournament-bracket/components/Bracket/bracket.module.css @@ -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);