/** * Bracket geometry, derived from the real feeder graph. * * The renderer used to place cards by index within a round — match i at * `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed * match k. That holds only when each round is an exact halving of the previous one. * * The LLWS winners bracket is not a halving: two of the four Opening Round games skip * Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT). * Under index math those games get pulled to the bottom of column one with nothing * above them in column two, and the connectors confidently join the wrong pairs. * * So lay out from the graph instead: * column = depth from the group's final, counted backwards * vertical order = the parent's slot order (participant1 above participant2) * connectors = actual feeder edges * * Counting columns back from the final is what makes a printed bracket line up: a team * entering late sits in the column where it actually plays, not the column its round * name suggests. For the LLWS International side this reproduces the official bracket * exactly, including putting the Australia/Mexico game alongside Winners Round 2. * * Pure — no React, no DB — so the geometry can be asserted against the printed bracket * in tests. */ import { llwsSideAndLocal, type BracketTemplate, } from "~/lib/bracket-templates"; import { resolveLLWSAdvancement } from "~/lib/llws-bracket"; // ── Feeder graph ────────────────────────────────────────────────────────────── export interface MatchRef { round: string; matchNumber: number; } /** What fills one participant slot of a match. */ export type SlotSource = | { kind: "match"; ref: MatchRef; result: "winner" | "loser" } | { kind: "seed" }; /** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */ export type FeederMap = Map; const SEED: SlotSource = { kind: "seed" }; /** * `template.id:roundName` for transitions routed by a dedicated advancement function * rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to * halve so the check in buildFeederMap can't rule them out on shape alone. * * The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10 * winner (advanceNBAPlayInWinner), which no winners-only halving describes. */ const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]); export function matchKey(round: string, matchNumber: number): string { return `${round}#${matchNumber}`; } /** * Invert a template's advancement rules into "what fills each slot". * * `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so * it is inverted from that. Everything else follows the standard rule: slot p1 of match N * is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N. */ export function buildFeederMap(template: BracketTemplate | undefined): FeederMap { const feeders: FeederMap = new Map(); if (!template) return feeders; const slots = (key: string): [SlotSource, SlotSource] => { let pair = feeders.get(key); if (!pair) { pair = [SEED, SEED]; feeders.set(key, pair); } return pair; }; // Seed every match in the template so unfed slots read as directly seeded. for (const round of template.rounds) { for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n)); } if (template.id === "llws_20") { for (const round of template.rounds) { for (let n = 1; n <= round.matchCount; n++) { const { winner, loser } = resolveLLWSAdvancement(round.name, n); const ref: MatchRef = { round: round.name, matchNumber: n }; for (const [destination, result] of [ [winner, "winner"], [loser, "loser"], ] as const) { if (!destination) continue; const pair = slots(matchKey(destination.round, destination.matchNumber)); pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result }; } } } return feeders; } // Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard // Round feeds the Elimination Finals, skipping the round printed next to it. for (const prev of template.rounds) { if (!prev.feedsInto) continue; const round = template.rounds.find((r) => r.name === prev.feedsInto); if (!round) continue; // advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by // parity. That describes the bracket only where the round halves exactly; a // play-in, a bye round, or a First Four routes by rules of its own, and inventing // a halving there would draw connectors and slot labels that are simply wrong. // Leaving those edges out drops the group to computeGroupLayout's fallback, which // is the geometry these brackets already had. if (prev.matchCount !== round.matchCount * 2) continue; if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue; for (let n = 1; n <= round.matchCount; n++) { const pair = slots(matchKey(round.name, n)); pair[0] = { kind: "match", ref: { round: prev.name, matchNumber: 2 * n - 1 }, result: "winner", }; pair[1] = { kind: "match", ref: { round: prev.name, matchNumber: 2 * n }, result: "winner", }; } } return feeders; } // ── Slot labels ─────────────────────────────────────────────────────────────── /** * Round names as they read inside a card, where there is room for about twenty * characters. Anything not listed keeps its full name. */ const SHORT_ROUND_NAMES: Record = { "Opening Round": "Opening", "Winners Round 2": "Winners R2", "Winners Semifinals": "Winners SF", "Winners Final": "Winners Final", "Elimination Round 1": "Elim R1", "Elimination Round 2": "Elim R2", "Elimination Round 3": "Elim R3", "Elimination Round 4": "Elim R4", "Elimination Final": "Elim Final", "Bracket Championship": "Bracket Final", }; /** * How an empty slot should read: "Winner of Winners SF 2" rather than "TBD". * * Returns null for a directly seeded slot, which the caller renders as "TBD". * * The cross-bracket feeds matter most here — a winners-bracket loser dropping into the * elimination bracket is a real edge that no line can show, because the two sides render * as separate trees. */ export function describeSlotSource( source: SlotSource | undefined, template: BracketTemplate | undefined ): string | null { if (!source || source.kind !== "match") return null; const { round, matchNumber } = source.ref; const name = SHORT_ROUND_NAMES[round] ?? round; const verb = source.result === "winner" ? "Winner" : "Loser"; const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0; if (template?.id !== "llws_20") { return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`; } // LLWS numbers matches globally across both sides, so semifinal 4 is International // semifinal 2. Name it the way the printed bracket does — by side-local number, or by // side where each side plays only one such game and the number would say nothing. const { side, localMatch } = llwsSideAndLocal(round, matchNumber); const isShared = round === "Consolation Third Place" || round === "World Championship"; const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2; if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`; if (isShared) return `${verb} of ${name}`; return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`; } // ── Layout ──────────────────────────────────────────────────────────────────── export interface LaidOutMatch { match: M; /** Centre of the card, in slot units (1 unit = one leaf row). */ center: number; } export interface LayoutColumn { label: string; matches: LaidOutMatch[]; } export interface BracketLayout { columns: LayoutColumn[]; /** Number of leaf rows; multiply by row height for the pixel height of the bracket. */ leafCount: number; /** Edges to draw, as (column index of the source, source centre, target centre). */ edges: { fromColumn: number; fromCenter: number; toCenter: number }[]; } interface PositionedMatch { round: string; matchNumber: number; /** Only read by the fallback, to trace edges through an unrecognised shape. */ winnerId?: string | null; participant1Id?: string | null; participant2Id?: string | null; } /** * Lay out one rendered group — a winners bracket, an elimination bracket, a region. * * `matchesByRound` should already be filtered to the group; cross-group feeds are * dropped, matching the printed bracket, which labels those slots rather than drawing * lines to another tree. * * Falls back to the previous index-based geometry when the group has no single root * (disjoint or unrecognised shapes), so no existing template can regress to a blank * column. */ export function computeGroupLayout( visibleRounds: string[], matchesByRound: Map, feeders: FeederMap, templateRoundOrder: string[] ): BracketLayout { const nodes = new Map(); const roundOf = new Map(); for (const round of visibleRounds) { for (const match of matchesByRound.get(round) ?? []) { const key = matchKey(match.round, match.matchNumber); nodes.set(key, match); roundOf.set(key, round); } } if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] }; // In-group children, in slot order. A slot fed from outside the group has no card // here, so it contributes no edge. const childrenOf = new Map(); const hasParent = new Set(); for (const key of nodes.keys()) { const pair = feeders.get(key); const kids: string[] = []; for (const source of pair ?? []) { if (source.kind !== "match") continue; const childKey = matchKey(source.ref.round, source.ref.matchNumber); if (!nodes.has(childKey) || kids.includes(childKey)) continue; kids.push(childKey); hasParent.add(childKey); } childrenOf.set(key, kids); } const roots = [...nodes.keys()].filter((k) => !hasParent.has(k)); if (roots.length !== 1) { return fallbackLayout(visibleRounds, matchesByRound); } const [root] = roots; // Depth from the root, then flip so leaves are column 0 and the final is last. // // Take the longest path, not the first one found: in a double-elimination bracket a // match feeds two places (its winner forward, its loser into the elimination side), so // the graph is a DAG and a node can be reached at several depths. The longest path is // the one that leaves room for every game on the way. const depth = new Map(); const assignDepth = (key: string, d: number) => { const known = depth.get(key); if (known !== undefined && known >= d) return; depth.set(key, d); for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1); }; assignDepth(root, 0); if (depth.size !== nodes.size) { return fallbackLayout(visibleRounds, matchesByRound); } const maxDepth = Math.max(...depth.values()); const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0); // Vertical order comes from a depth-first walk in slot order: participant1's feeder // sits above participant2's. This is why the elimination bracket's later game ends up // on top, as the printed bracket has it. const center = new Map(); let leafCount = 0; const place = (key: string): number => { const already = center.get(key); if (already !== undefined) return already; const kids = childrenOf.get(key) ?? []; if (kids.length === 0) { const y = leafCount + 0.5; leafCount += 1; center.set(key, y); return y; } const kidCenters = kids.map(place); const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length; center.set(key, y); return y; }; place(root); const columns: LayoutColumn[] = Array.from({ length: maxDepth + 1 }, () => ({ label: "", matches: [], })); for (const [key, match] of nodes) { columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 }); } for (const column of columns) { column.matches.sort((a, b) => a.center - b.center); } // A column can mix rounds — the LLWS second column holds two Opening Round games // alongside Winners Round 2. Name it for the latest round it contains, which is how // the printed bracket labels that column. for (let ci = 0; ci < columns.length; ci++) { const rounds = columns[ci].matches.map((m) => m.match.round); columns[ci].label = rounds.reduce((latest, r) => templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest ); } // Connectors live in the single gutter between adjacent columns, so only edges that // span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a // feed can reach further back, and a line that stopped short would be worse than none. const edges: BracketLayout["edges"] = []; for (const [key] of nodes) { const toCenter = center.get(key) ?? 0; for (const child of childrenOf.get(key) ?? []) { const fromColumn = columnOf(child); if (fromColumn !== columnOf(key) - 1) continue; edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter }); } } return { columns, leafCount, edges }; } /** * The previous behaviour, kept for groups whose shape can't be resolved into a single * tree: one column per round, matches spread evenly over it, and edges inferred from the * round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template) * land here, so it has to keep drawing what they drew before rather than nothing. */ function fallbackLayout( visibleRounds: string[], matchesByRound: Map ): BracketLayout { const leafCount = Math.max( ...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1 ); const centersFor = (matches: M[]) => { const span = leafCount / Math.max(matches.length, 1); return matches.map((_, i) => (i + 0.5) * span); }; const columns = visibleRounds.map((round) => { const matches = matchesByRound.get(round) ?? []; const centers = centersFor(matches); return { label: round, matches: matches.map((match, i) => ({ match, center: centers[i] })), }; }); const edges: BracketLayout["edges"] = []; for (let ci = 0; ci < columns.length - 1; ci++) { const from = columns[ci].matches; const to = columns[ci + 1].matches; if (to.length === Math.ceil(from.length / 2) && from.length > 1) { // A halving: matches 2k and 2k+1 feed match k. for (let k = 0; k < to.length; k++) { for (const idx of [2 * k, 2 * k + 1]) { if (idx >= from.length) continue; edges.push({ fromColumn: ci, fromCenter: from[idx].center, toCenter: to[k].center, }); } } continue; } // Otherwise the only thing that can be known is where a winner actually went, so // nothing is drawn until the games are played. const winnerToCenter = new Map(); for (const { match, center } of from) { if (match.winnerId) winnerToCenter.set(match.winnerId, center); } for (const { match, center } of to) { for (const id of [match.participant1Id, match.participant2Id]) { const fromCenter = id ? winnerToCenter.get(id) : undefined; if (fromCenter === undefined) continue; edges.push({ fromColumn: ci, fromCenter, toCenter: center }); } } } return { columns, leafCount, edges }; }