"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;
/** ISO 3166-1 alpha-2 code used to load the flag from flagcdn.com (England is gb-eng). */
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;
date: string;
time?: string;
status: "finished" | "upcoming";
home: MatchSide;
away: MatchSide;
/** Decides the marker and which side dims. */
winner?: "home" | "away";
};
export type Round = {
name: string;
matches: Match[];
};
export interface KnockoutBracketProps {
/** Ordered rounds; each must hold half as many matches as the one before (16 → 8 → 4 → 2 → 1). */
rounds: Round[];
/** Round shown as the leftmost column on mount. Defaults to 1, clamped to the valid range. */
initialRound?: number;
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 (
);
}
function TeamFlag({ code }: { code: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
);
}
return (
// Plain served by flagcdn.com — swap this if you need self-hosted assets.
// biome-ignore lint/performance/noImgElement: remote flagcdn asset, no next/image benefit
setFailed(true)}
className="h-5 w-7 shrink-0 rounded-[4px] border border-border/40 object-cover"
/>
);
}
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,
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++) {
centers[r] = rounds[r].matches.map(
(_, k) => (centers[r - 1][2 * k] + centers[r - 1][2 * k + 1]) / 2,
);
}
// 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)];
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];
const yBot = centers[r - 1][2 * k + 1];
list.push({
key: `${r}-${k}`,
x: feederRight,
y: yTop,
height: Math.max(0, yBot - yTop),
visible,
});
});
}
const baseCount = base.matches.length;
return {
cy: centers,
containerHeight: (baseCount - 1) * ROW + 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 (