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);