"use client";
import { ChevronLeft, ChevronRight, Shield } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useMemo, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type Team = {
name: string;
/**
* Any square image URL — club crest, org mark, player photo. Wins over `code`.
* Drawn as-is on the card surface, so a transparent-background mark inked for
* one theme disappears in the other: ship artwork that reads on both, or pick
* the URL yourself from your theme state.
*/
logo?: string;
/** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */
code?: string;
};
export type MatchSide = {
/** null renders a TBD slot with a shield icon. */
team: Team | null;
score: number | null;
/** Present on both sides to render Google-style shootout scores — 1 (3). */
penalties?: number | null;
};
export type Match = {
id: string;
/** Kick-off day, already formatted. Omit it and the card drops the date row. */
date?: string;
time?: string;
/** Omit it and a match with a `winner` counts as finished. */
status?: "finished" | "upcoming";
home: MatchSide;
away: MatchSide;
/** Decides the marker and which side dims. */
winner?: "home" | "away";
/** Replaces the derived result chip ("FT", "FT (P)") — e.g. "AET", "BO5", "Forfeit". */
badge?: string;
};
export type Round = {
/** Shown as the column header — "Round of 32", "Upper bracket final", "Last 8". */
name: string;
matches: Match[];
};
export interface KnockoutBracketProps {
/**
* The whole draw, ordered widest round first. Any single-elimination
* tournament fits: each round holds half the matches of the one before it
* (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]` is fed by matches `2k` and
* `2k + 1` of the round before it. Two rounds are enough.
*/
rounds: Round[];
/** Round shown as the leftmost column on mount. Defaults to 1, clamped to the valid range. */
initialRound?: number;
/** Third place play-off, rendered under the bracket instead of inside it. */
thirdPlace?: Match;
/** Heading over `thirdPlace`. Defaults to "Third place play-off". */
thirdPlaceLabel?: string;
className?: string;
}
// Card geometry drives the whole computed layout — every later match sits at the
// exact vertical midpoint of its two feeders, so pairs line up with connectors.
// Keep CARD_H in sync with the card's internal spacing.
const CARD_W = 250;
const CARD_H = 124;
// Pocket (20) + stem (20) — matches the CSS `]` connector geometry.
const GAP_X = 40;
const GAP_Y = 20;
const COL_W = CARD_W + GAP_X;
const ROW = CARD_H + GAP_Y;
const VISIBLE_COLS = 3;
const CONNECTOR_POCKET = 20;
const CONNECTOR_STEM = GAP_X - CONNECTOR_POCKET;
// Tall enough for 44px chevron hit areas without clipping the focus ring.
const HEADER_H = 44;
// Breathing room baked into the computed layout so the base column isn't flush
// against the clip edge and connector nubs aren't shaved off.
const PAD_X = 8;
const PAD_Y = 12;
// Firmer than SPRING_LAYOUT so the many cards, connectors and stage height
// glide as one piece; damping just over critical (~1.05) settles with no bounce
// and no lazy overdamped tail.
const REFLOW = {
type: "spring",
stiffness: 260,
damping: 32,
mass: 0.9,
} as const;
// Opacity cross-fades a touch ahead of the position spring so columns don't
// ghost while sliding.
const REFLOW_OPACITY = {
duration: 0.28,
ease: EASE_OUT,
} as const;
const clamp = (n: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, n));
// Column x-offset and window test — shared by the render pass and the memoized
// layout so the two can't drift. Module-level (stable identity) so the layout
// memo can call them without widening its dependency list.
const colX = (r: number, page: number) => PAD_X + (r - page) * COL_W;
const isInWindow = (r: number, page: number, visibleCols: number) =>
r >= page && r < page + visibleCols;
type Connector = {
key: string;
/** Feeder card right edge — left of the `]` pocket. */
x: number;
/** Top feeder center Y. */
y: number;
/** Distance between the two feeder centers. */
height: number;
visible: boolean;
};
// CSS `]` pocket + stem: border-y/border-r + a hairline to the child.
// Transform/opacity only — no SVG path morph, so paging stays flicker-free.
function BracketConnector({
connector,
transition,
}: {
connector: Connector;
transition: object;
}) {
const { x, y, height, visible } = connector;
const geo = visible
? transition
: {
...transition,
x: { duration: 0 },
y: { duration: 0 },
height: { duration: 0 },
};
return (
);
}
/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */
const crestSrc = (team: Team) =>
team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);
/** Two-letter stand-in when a team has no artwork — "Real Madrid" → RM.
* Spread, not `word[0]`: an emoji or astral first character is a surrogate pair
* and indexing it renders a replacement glyph. */
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((word) => [...word][0])
.join("")
.toUpperCase();
// Fixed 28px slot whatever it holds, so names and scores stay aligned down the
// column: flag, crest, initials or the TBD shield.
function TeamCrest({ team }: { team: Team | null }) {
// The failed URL, not a boolean: a corrected logo on the same match should be
// tried again rather than stay initials for the life of the card.
const [failedSrc, setFailedSrc] = useState(null);
const resolved = team ? crestSrc(team) : null;
const src = resolved === failedSrc ? null : resolved;
return (
{src ? (
// Plain — flags come from flagcdn.com, logos from wherever you host them.
// biome-ignore lint/performance/noImgElement: remote asset, no next/image benefit
setFailedSrc(src)}
className={cn(
"shrink-0 rounded-[4px] border border-border/40",
// Flags are 4:3 and fill the slot; a logo keeps its own shape inside it.
team?.logo
? "h-5 w-5 border-transparent object-contain"
: "h-5 w-7 object-cover",
)}
/>
) : team ? (
// text-foreground, not muted: the /10 tint lifts the disc toward the
// muted ramp, leaving 4.44:1 light and 3.55:1 dark — both under AA.
{initials(team.name)}
) : (
)}
);
}
function WinnerMarker() {
return (
);
}
function TeamRow({
side,
isWinner,
decided,
}: {
side: MatchSide;
isWinner: boolean;
decided: boolean;
}) {
const dim = decided && !isWinner;
return (
);
}
export function KnockoutBracket({
rounds,
initialRound = 1,
thirdPlace,
thirdPlaceLabel = "Third place play-off",
className,
}: KnockoutBracketProps) {
const reduce = useReducedMotion();
const visibleCols = Math.min(VISIBLE_COLS, rounds.length);
// The last page shows the final two rounds (semi-finals + final), not a full
// window — so paging continues past the QF/SF/Final view down to SF + Final.
const maxPage = Math.max(0, rounds.length - Math.min(2, rounds.length));
const [page, setPage] = useState(() => clamp(initialRound, 0, maxPage));
// Shared reflow — cards, connectors, headers and stage height page together.
// Height springs with the same token (layout morph is the product feel for
// collapsing rounds); opacity uses a short ease so fades don't lag the glide.
const transition = reduce
? { duration: 0 }
: { ...REFLOW, opacity: REFLOW_OPACITY };
const pageStatus = useMemo(() => {
const names = rounds
.slice(page, page + visibleCols)
.map((round) => round.name);
if (names.length <= 1) return `Showing ${names[0] ?? "rounds"}`;
if (names.length === 2) return `Showing ${names[0]} and ${names[1]}`;
return `Showing ${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
}, [rounds, page, visibleCols]);
// Layout is computed, not scrolled. The leftmost visible round (`page`) is the
// base and stacks at a fixed rhythm; every later match centers on its feeders,
// and behind rounds spread out (below). Cards and connectors derive from one
// pass and page together under the shared transition.
const { cy, containerHeight, connectors } = useMemo(() => {
const centers: number[][] = new Array(rounds.length);
const base = rounds[page];
centers[page] = base.matches.map((_, i) => PAD_Y + i * ROW + CARD_H / 2);
for (let r = page + 1; r < rounds.length; r++) {
const feeders = centers[r - 1];
const row: number[] = [];
for (let k = 0; k < rounds[r].matches.length; k++) {
const top = feeders[2 * k];
if (top == null) {
// A round with more matches than its feeders allow (an odd draw, a bye
// left out) stacks a full row under the last card placed in this
// round — a fixed rhythm from the top can land on top of a midpoint.
const prev = row[k - 1];
row[k] = prev == null ? PAD_Y + CARD_H / 2 : prev + ROW;
} else {
row[k] = (top + (feeders[2 * k + 1] ?? top)) / 2;
}
}
centers[r] = row;
}
// Behind rounds keep their natural spread (spacing halves each step out,
// each match straddling its parent) instead of collapsing, so paging back
// slides a formed column in from the left just as paging forward does.
for (let r = page - 1; r >= 0; r--) {
const half = ROW / 2 ** (page - r + 1);
centers[r] = rounds[r].matches.map((_, i) => {
const parent = centers[r + 1][Math.floor(i / 2)] ?? PAD_Y;
return parent + (i % 2 === 0 ? -half : half);
});
}
const list: Connector[] = [];
for (let r = 1; r < rounds.length; r++) {
const feederRight = colX(r - 1, page) + CARD_W;
const visible =
isInWindow(r, page, visibleCols) &&
isInWindow(r - 1, page, visibleCols);
rounds[r].matches.forEach((_, k) => {
const yTop = centers[r - 1][2 * k] ?? centers[r][k];
const yBot = centers[r - 1][2 * k + 1] ?? yTop;
list.push({
key: `${r}-${k}`,
x: feederRight,
y: yTop,
height: Math.max(0, yBot - yTop),
visible,
});
});
}
// Measured, not derived from the base count: a fallback-stacked round can
// run past the base column, and the stage clips its overflow.
// Seeded with one card's center so an empty round yields a real height
// rather than -Infinity.
const lowest = Math.max(
PAD_Y + CARD_H / 2,
...centers.slice(page, page + visibleCols).flat(),
);
return {
cy: centers,
containerHeight: lowest + CARD_H / 2 + PAD_Y,
connectors: list,
};
}, [rounds, page, visibleCols]);
const containerWidth =
visibleCols * CARD_W + (visibleCols - 1) * GAP_X + 2 * PAD_X;
return (
{pageStatus}
{/* Only the gliding round titles are clipped (they enter/exit at the
canvas edge); the chevron buttons sit outside that clip. */}
{rounds.map((round, r) => (
{round.name}
))}
{page > 0 && (
)}
{page < maxPage && (
)}
{/* Stage height springs with REFLOW so the bracket collapses as one
piece with the cards — layout property is intentional here. */}
{connectors.map((c) => (
))}
{rounds.map((round, r) => {
const roundVisible = isInWindow(r, page, visibleCols);
return (
{round.matches.map((match, k) => (
))}
);
})}
{/* Outside the bracket stage — it feeds off the semi-finals rather than
into the final, so it gets its own rule instead of a column. */}
{thirdPlace && (