import { avatarColor } from "~/lib/avatar-colors"; import { BRACKT_GRADIENT } from "~/lib/brand"; import { computeGroupLayout, describeSlotSource, matchKey, type BracketLayout, type FeederMap, } from "~/lib/bracket-layout"; import type { BracketTemplate } from "~/lib/bracket-templates"; export interface BracketMatch { id: string; round: string; matchNumber: number; participant1Id: string | null; participant2Id: string | null; winnerId: string | null; loserId: string | null; isComplete: boolean; participant1Score: string | null; participant2Score: string | null; isScoring?: boolean; participant1?: { id: string; name: string } | null; participant2?: { id: string; name: string } | null; winner?: { id: string; name: string } | null; loser?: { id: string; name: string } | null; } export interface BracketOwnership { participantId: string; teamName: string; teamId: string; ownerName?: string; } const COLUMN_WIDTH = 152; const CONNECTOR_WIDTH = 24; export const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; export const LABEL_HEIGHT = 32; export const CARD_GAP = 14; export const DESIRED_CARD_HEIGHT = 112; export const MAX_CARD_HEIGHT = 140; function formatScore(score: string | null): string | null { if (!score) return null; const n = parseFloat(score); if (isNaN(n)) return null; return Number.isInteger(n) ? String(n) : n.toFixed(1); } // ─── Match Slot ────────────────────────────────────────────────────────────── interface ParticipantRowProps { name: string | null; /** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */ feedLabel?: string | null; isTbd: boolean; isWinner: boolean; isLoser: boolean; isOwned: boolean; ownership: BracketOwnership | null; score: string | null; rowHeight: number; showScore: boolean; showOwner: boolean; showText: boolean; } function ParticipantRow({ name, feedLabel, isTbd, isWinner, isLoser, isOwned, ownership, score, rowHeight, showScore, showOwner, showText, }: ParticipantRowProps) { const formattedScore = formatScore(score); return (
{showText && (
{/* Manager avatar — always present for alignment; empty box when unowned */}
{ownership && ownership.teamName .split(/\s+/) .slice(0, 2) .map((w) => w[0]?.toUpperCase() ?? "") .join("")}
{name ?? feedLabel ?? "TBD"} {/* Owner name below participant name */} {showOwner && !isTbd && ownership && ( {ownership.ownerName ?? ownership.teamName} )}
{/* Score */} {showScore && formattedScore && ( {formattedScore} )}
)}
); } interface BracketMatchSlotProps { match: BracketMatch; slotHeight: number; ownershipMap: Map; userParticipantIds: Set; feeders?: FeederMap; template?: BracketTemplate; } export function BracketMatchSlot({ match, slotHeight, ownershipMap, userParticipantIds, feeders, template, }: BracketMatchSlotProps) { const rowHeight = slotHeight / 2; const showText = rowHeight >= 10; const showScore = rowHeight >= 20 && (!!match.participant1Score || match.isComplete); const showOwner = rowHeight >= 36; const p1Id = match.participant1Id; const p2Id = match.participant2Id; const p1IsWinner = match.isComplete && match.winnerId === p1Id; const p2IsWinner = match.isComplete && match.winnerId === p2Id; const p1IsLoser = match.isComplete && match.loserId === p1Id; const p2IsLoser = match.isComplete && match.loserId === p2Id; const p1IsOwned = !!(p1Id && userParticipantIds.has(p1Id)); const p2IsOwned = !!(p2Id && userParticipantIds.has(p2Id)); const matchHasOwned = p1IsOwned || p2IsOwned; const isTbd1 = !p1Id; const isTbd2 = !p2Id; const p1Ownership = p1Id ? ownershipMap.get(p1Id) ?? null : null; const p2Ownership = p2Id ? ownershipMap.get(p2Id) ?? null : null; // Corona glow: gradient for complete matches, electric for user's picks, subtle white for pending const coronaStyle: React.CSSProperties = match.isComplete ? { background: BRACKT_GRADIENT } : matchHasOwned ? { background: "rgba(44, 225, 193, 0.4)" } : { background: "rgba(255, 255, 255, 0.07)" }; const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20))); // An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for // the feeds that cross between the winners and elimination brackets, which render as // separate trees and so can never be joined by a line. const slotSources = feeders?.get(matchKey(match.round, match.matchNumber)); const feed1 = describeSlotSource(slotSources?.[0], template); const feed2 = describeSlotSource(slotSources?.[1], template); return (
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
{/* Inner card, inset on right to expose corona glow strip */}
); } // ─── Connector column ───────────────────────────────────────────────────────── interface ConnectorColumnProps { /** Edges crossing this gutter, in slot units. */ edges: { fromCenter: number; toCenter: number }[]; rowHeight: number; offset: number; bracketHeight: number; } /** * Draws the feeder edges crossing one gutter. Because the layout assigns columns by * depth from the final, every edge spans exactly one gutter — so a card that enters the * bracket late is drawn in the column where it actually plays, and there is never an * edge to route across a skipped column. */ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) { const mid = CONNECTOR_WIDTH / 2; // Merge the two edges feeding one card into a single elbow, so a pair reads as one // bracket join rather than two overlapping lines. const byTarget = new Map(); for (const { fromCenter, toCenter } of edges) { const sources = byTarget.get(toCenter) ?? []; sources.push(fromCenter); byTarget.set(toCenter, sources); } const paths: string[] = []; for (const [toCenter, sources] of byTarget) { const destY = toCenter * rowHeight - offset; const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b); if (ys.length === 1) { paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`); continue; } paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`); for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`); paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`); } return (
{paths.map((d) => ( ))}
); } // ─── Tree columns (shared by full + paginated) ─────────────────────────────── export interface BracketGeometry { layout: BracketLayout; /** Height of one leaf row. */ rowHeight: number; /** Height of the card area, excluding the round labels. */ bracketHeight: number; /** Narrowest the columns and gutters can be drawn without overlapping. */ minWidth: number; /** Pixels trimmed off the top, non-zero only for a cropped column window. */ offset: number; } /** * Lay out a group's matches from the feeder graph and derive its pixel geometry. * * Height comes from the number of leaf rows rather than the largest round, so a bracket * whose widest column isn't its first still gets the room it needs. */ export function bracketGeometry( visibleRounds: string[], matchesByRound: Map, feeders: FeederMap | undefined, templateRoundOrder: string[] ): BracketGeometry { const layout = computeGroupLayout( visibleRounds, matchesByRound, feeders ?? new Map(), templateRoundOrder ); const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP; const columnCount = Math.max(layout.columns.length, 1); return { layout, rowHeight, bracketHeight: Math.max(layout.leafCount, 1) * rowHeight, minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH, offset: 0, }; } /** * Crop a layout to a window of columns, as the mobile pager does. * * Card positions are absolute within the whole bracket, so showing a slice of columns * means trimming the empty space above them rather than re-flowing — otherwise a later * page would render its two columns stranded at the bottom of a full-height bracket. */ export function windowGeometry( geometry: BracketGeometry, firstColumn: number, lastColumn: number ): BracketGeometry { const centers = geometry.layout.columns .slice(firstColumn, lastColumn + 1) .flatMap((c) => c.matches.map((m) => m.center)); if (centers.length === 0) return geometry; const min = Math.min(...centers); const max = Math.max(...centers); return { ...geometry, bracketHeight: (max - min + 1) * geometry.rowHeight, offset: (min - 0.5) * geometry.rowHeight, }; } interface TreeColumnsProps { geometry: BracketGeometry; ownershipMap: Map; userParticipantIds: Set; transitionDuration?: number; feeders?: FeederMap; template?: BracketTemplate; /** Restrict rendering to a window of columns (used by the mobile pager). */ columnRange?: [number, number]; } export function TreeColumns({ geometry, ownershipMap, userParticipantIds, transitionDuration, feeders, template, columnRange, }: TreeColumnsProps) { const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined; const { layout, rowHeight, bracketHeight, offset } = geometry; const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1]; const visible = layout.columns.slice(firstColumn, lastColumn + 1); // Cards keep a fixed height regardless of how many share a column — stretching a // lone final to fill its column is what made it tower over the rest of the bracket. const cardHeight = Math.min( Math.max(rowHeight - CARD_GAP, 1), MAX_CARD_HEIGHT ); return (
{visible.map((column, vi) => { const ci = firstColumn + vi; const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci); return (
{/* Round column */}
{column.label}
{column.matches.map(({ match, center }) => (
))}
{/* Connector between this column and the next */} {vi < visible.length - 1 && ( )}
); })}
); } // ─── Full desktop tree ──────────────────────────────────────────────────────── interface BracketTreeViewProps { rounds: string[]; matchesByRound: Map; ownershipMap: Map; userParticipantIds: Set; thirdPlaceRound?: string; feeders?: FeederMap; template?: BracketTemplate; } export function BracketTreeView({ rounds, matchesByRound, ownershipMap, userParticipantIds, thirdPlaceRound, feeders, template, }: BracketTreeViewProps) { const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; const geometry = bracketGeometry( mainRounds, matchesByRound, feeders, template?.rounds.map((r) => r.name) ?? mainRounds ); const { bracketHeight, minWidth } = geometry; return (
{thirdPlaceMatch && (
3rd Place
)}
); }