Compare commits
3 commits
8edb4293c5
...
2356e37163
| Author | SHA1 | Date | |
|---|---|---|---|
| 2356e37163 | |||
|
|
569081fe29 | ||
|
|
89ceee432a |
16 changed files with 1905 additions and 562 deletions
|
|
@ -1,13 +1,16 @@
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||||
|
import type { FeederMap } from "~/lib/bracket-layout";
|
||||||
|
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||||
import {
|
import {
|
||||||
TreeColumns,
|
TreeColumns,
|
||||||
BracketMatchSlot,
|
BracketMatchSlot,
|
||||||
|
bracketGeometry,
|
||||||
|
windowGeometry,
|
||||||
SLOT_WIDTH,
|
SLOT_WIDTH,
|
||||||
LABEL_HEIGHT,
|
LABEL_HEIGHT,
|
||||||
DESIRED_CARD_HEIGHT,
|
DESIRED_CARD_HEIGHT,
|
||||||
CARD_GAP,
|
|
||||||
MAX_CARD_HEIGHT,
|
MAX_CARD_HEIGHT,
|
||||||
type BracketMatch,
|
type BracketMatch,
|
||||||
type BracketOwnership,
|
type BracketOwnership,
|
||||||
|
|
@ -21,6 +24,8 @@ interface BracketTreePaginatedProps {
|
||||||
/** Index of the first scoring round — default page starts here */
|
/** Index of the first scoring round — default page starts here */
|
||||||
firstScoringRoundIdx?: number;
|
firstScoringRoundIdx?: number;
|
||||||
thirdPlaceRound?: string;
|
thirdPlaceRound?: string;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreePaginated({
|
export function BracketTreePaginated({
|
||||||
|
|
@ -30,63 +35,68 @@ export function BracketTreePaginated({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
firstScoringRoundIdx,
|
firstScoringRoundIdx,
|
||||||
thirdPlaceRound,
|
thirdPlaceRound,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: BracketTreePaginatedProps) {
|
}: BracketTreePaginatedProps) {
|
||||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||||
|
|
||||||
|
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
|
||||||
|
// when teams enter the bracket at different points (see computeGroupLayout).
|
||||||
|
const geometry = bracketGeometry(
|
||||||
|
mainRounds,
|
||||||
|
matchesByRound,
|
||||||
|
feeders,
|
||||||
|
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||||
|
);
|
||||||
|
const columns = geometry.layout.columns;
|
||||||
|
const lastPage = Math.max(columns.length - 2, 0);
|
||||||
|
|
||||||
const defaultPage = Math.max(
|
const defaultPage = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
firstScoringRoundIdx !== undefined
|
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
||||||
? Math.max(0, firstScoringRoundIdx - 1)
|
lastPage,
|
||||||
: mainRounds.length - 2,
|
|
||||||
mainRounds.length - 2,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||||
mainRounds.length - 2,
|
lastPage,
|
||||||
defaultPage,
|
defaultPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetPage = anim ? anim.toPage : page;
|
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
||||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
const labelFor = (p: number) => {
|
||||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
||||||
|
return b ? `${a} → ${b}` : (a ?? "");
|
||||||
const calcHeight = (p: number) => {
|
|
||||||
const rs = mainRounds.slice(p, p + 2);
|
|
||||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
|
||||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageHeight = calcHeight(page);
|
const label = labelFor(anim ? anim.toPage : page);
|
||||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
|
||||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
|
||||||
|
|
||||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
const pageG = pageGeometry(page);
|
||||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||||
|
|
||||||
let leftRounds: string[];
|
let leftPage: number;
|
||||||
let rightRounds: string[] = [];
|
let rightPage: number | null = null;
|
||||||
let leftHeight: number;
|
let leftG = pageG;
|
||||||
let rightHeight = 0;
|
let rightG = pageG;
|
||||||
let settlingTransition = false;
|
let settlingTransition = false;
|
||||||
if (anim?.phase === "sliding") {
|
if (anim?.phase === "sliding") {
|
||||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
||||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
||||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
leftG = anim.dir === "right" ? animFromG : animToG;
|
||||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
rightG = anim.dir === "right" ? animToG : animFromG;
|
||||||
} else if (anim?.phase === "settling") {
|
} else if (anim?.phase === "settling") {
|
||||||
leftRounds = toRounds;
|
leftPage = anim.toPage;
|
||||||
leftHeight = animToHeight;
|
leftG = animToG;
|
||||||
settlingTransition = true;
|
settlingTransition = true;
|
||||||
} else {
|
} else {
|
||||||
leftRounds = visibleRounds;
|
leftPage = page;
|
||||||
leftHeight = pageHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
const containerMinHeight =
|
||||||
|
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -109,7 +119,7 @@ export function BracketTreePaginated({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(page + 1)}
|
onClick={() => navigate(page + 1)}
|
||||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
disabled={page >= lastPage || !!anim}
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
aria-label="Next rounds"
|
aria-label="Next rounds"
|
||||||
>
|
>
|
||||||
|
|
@ -129,22 +139,24 @@ export function BracketTreePaginated({
|
||||||
>
|
>
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={leftRounds}
|
geometry={leftG}
|
||||||
matchesByRound={matchesByRound}
|
columnRange={[leftPage, leftPage + 1]}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={leftHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
transitionDuration={settlingTransition ? 500 : undefined}
|
transitionDuration={settlingTransition ? 500 : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{anim?.phase === "sliding" && (
|
{anim?.phase === "sliding" && rightPage !== null && (
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={rightRounds}
|
geometry={rightG}
|
||||||
matchesByRound={matchesByRound}
|
columnRange={[rightPage, rightPage + 1]}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={rightHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -164,6 +176,8 @@ export function BracketTreePaginated({
|
||||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { avatarColor } from "~/lib/avatar-colors";
|
import { avatarColor } from "~/lib/avatar-colors";
|
||||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
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 {
|
export interface BracketMatch {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null {
|
||||||
|
|
||||||
interface ParticipantRowProps {
|
interface ParticipantRowProps {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
||||||
|
feedLabel?: string | null;
|
||||||
isTbd: boolean;
|
isTbd: boolean;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
isLoser: boolean;
|
isLoser: boolean;
|
||||||
|
|
@ -60,6 +70,7 @@ interface ParticipantRowProps {
|
||||||
|
|
||||||
function ParticipantRow({
|
function ParticipantRow({
|
||||||
name,
|
name,
|
||||||
|
feedLabel,
|
||||||
isTbd,
|
isTbd,
|
||||||
isWinner,
|
isWinner,
|
||||||
isLoser,
|
isLoser,
|
||||||
|
|
@ -114,7 +125,7 @@ function ParticipantRow({
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
{name ?? "TBD"}
|
{name ?? feedLabel ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Owner name below participant name */}
|
{/* Owner name below participant name */}
|
||||||
|
|
@ -150,6 +161,8 @@ interface BracketMatchSlotProps {
|
||||||
slotHeight: number;
|
slotHeight: number;
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketMatchSlot({
|
export function BracketMatchSlot({
|
||||||
|
|
@ -157,6 +170,8 @@ export function BracketMatchSlot({
|
||||||
slotHeight,
|
slotHeight,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: BracketMatchSlotProps) {
|
}: BracketMatchSlotProps) {
|
||||||
const rowHeight = slotHeight / 2;
|
const rowHeight = slotHeight / 2;
|
||||||
const showText = rowHeight >= 10;
|
const showText = rowHeight >= 10;
|
||||||
|
|
@ -187,6 +202,13 @@ export function BracketMatchSlot({
|
||||||
|
|
||||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
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 (
|
return (
|
||||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||||
|
|
@ -208,6 +230,7 @@ export function BracketMatchSlot({
|
||||||
>
|
>
|
||||||
<ParticipantRow
|
<ParticipantRow
|
||||||
name={match.participant1?.name ?? null}
|
name={match.participant1?.name ?? null}
|
||||||
|
feedLabel={feed1}
|
||||||
isTbd={isTbd1}
|
isTbd={isTbd1}
|
||||||
isWinner={p1IsWinner}
|
isWinner={p1IsWinner}
|
||||||
isLoser={p1IsLoser}
|
isLoser={p1IsLoser}
|
||||||
|
|
@ -221,6 +244,7 @@ export function BracketMatchSlot({
|
||||||
/>
|
/>
|
||||||
<ParticipantRow
|
<ParticipantRow
|
||||||
name={match.participant2?.name ?? null}
|
name={match.participant2?.name ?? null}
|
||||||
|
feedLabel={feed2}
|
||||||
isTbd={isTbd2}
|
isTbd={isTbd2}
|
||||||
isWinner={p2IsWinner}
|
isWinner={p2IsWinner}
|
||||||
isLoser={p2IsLoser}
|
isLoser={p2IsLoser}
|
||||||
|
|
@ -237,54 +261,45 @@ export function BracketMatchSlot({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConnectorColumnProps {
|
interface ConnectorColumnProps {
|
||||||
currentMatches: BracketMatch[];
|
/** Edges crossing this gutter, in slot units. */
|
||||||
nextMatches: BracketMatch[];
|
edges: { fromCenter: number; toCenter: number }[];
|
||||||
|
rowHeight: number;
|
||||||
|
offset: number;
|
||||||
bracketHeight: number;
|
bracketHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
|
/**
|
||||||
|
* 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;
|
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<number, number[]>();
|
||||||
|
for (const { fromCenter, toCenter } of edges) {
|
||||||
|
const sources = byTarget.get(toCenter) ?? [];
|
||||||
|
sources.push(fromCenter);
|
||||||
|
byTarget.set(toCenter, sources);
|
||||||
|
}
|
||||||
|
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
|
for (const [toCenter, sources] of byTarget) {
|
||||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
const destY = toCenter * rowHeight - offset;
|
||||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
||||||
|
if (ys.length === 1) {
|
||||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
continue;
|
||||||
// Standard single-elimination halving: U-shape connectors
|
|
||||||
for (let k = 0; k < nextMatches.length; k++) {
|
|
||||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
|
||||||
const midY = k * nextSlotH + nextSlotH / 2;
|
|
||||||
const botIdx = 2 * k + 1;
|
|
||||||
|
|
||||||
if (botIdx < currentMatches.length) {
|
|
||||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
|
||||||
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
|
||||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
|
||||||
} else {
|
|
||||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
|
||||||
}
|
}
|
||||||
}
|
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
||||||
} else {
|
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
||||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
||||||
const winnerToIdx = new Map<string, number>();
|
|
||||||
currentMatches.forEach((m, idx) => {
|
|
||||||
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
|
|
||||||
});
|
|
||||||
|
|
||||||
nextMatches.forEach((nextMatch, nextIdx) => {
|
|
||||||
const destY = nextIdx * nextSlotH + nextSlotH / 2;
|
|
||||||
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
|
|
||||||
if (!pId) continue;
|
|
||||||
const srcIdx = winnerToIdx.get(pId);
|
|
||||||
if (srcIdx === undefined) continue;
|
|
||||||
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
|
|
||||||
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
||||||
|
|
||||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
||||||
|
|
||||||
|
export interface BracketGeometry {
|
||||||
|
layout: BracketLayout<BracketMatch>;
|
||||||
|
/** 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<string, BracketMatch[]>,
|
||||||
|
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 {
|
interface TreeColumnsProps {
|
||||||
visibleRounds: string[];
|
geometry: BracketGeometry;
|
||||||
matchesByRound: Map<string, BracketMatch[]>;
|
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
bracketHeight: number;
|
|
||||||
transitionDuration?: number;
|
transitionDuration?: number;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
|
/** Restrict rendering to a window of columns (used by the mobile pager). */
|
||||||
|
columnRange?: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeColumns({
|
export function TreeColumns({
|
||||||
visibleRounds,
|
geometry,
|
||||||
matchesByRound,
|
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
bracketHeight,
|
|
||||||
transitionDuration,
|
transitionDuration,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
|
columnRange,
|
||||||
}: TreeColumnsProps) {
|
}: TreeColumnsProps) {
|
||||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||||
return (
|
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
||||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
|
||||||
{visibleRounds.map((round, ri) => {
|
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
||||||
const roundMatches = matchesByRound.get(round) ?? [];
|
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
||||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
|
||||||
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
|
// Cards keep a fixed height regardless of how many share a column — stretching a
|
||||||
const cardTop = (slotHeight - cardHeight) / 2;
|
// lone final to fill its column is what made it tower over the rest of the bracket.
|
||||||
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
|
const cardHeight = Math.min(
|
||||||
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
|
Math.max(rowHeight - CARD_GAP, 1),
|
||||||
|
MAX_CARD_HEIGHT
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round} style={{ display: "contents" }}>
|
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
|
{visible.map((column, vi) => {
|
||||||
|
const ci = firstColumn + vi;
|
||||||
|
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={column.label + ci} style={{ display: "contents" }}>
|
||||||
{/* Round column */}
|
{/* Round column */}
|
||||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
{round}
|
{column.label}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{roundMatches.map((match, matchIdx) => (
|
{column.matches.map(({ match, center }) => (
|
||||||
<div
|
<div
|
||||||
key={match.id}
|
key={match.id}
|
||||||
|
data-match-id={match.id}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: matchIdx * slotHeight + cardTop,
|
top: center * rowHeight - offset - cardHeight / 2,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
|
|
@ -367,6 +461,8 @@ export function TreeColumns({
|
||||||
slotHeight={cardHeight}
|
slotHeight={cardHeight}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -374,10 +470,11 @@ export function TreeColumns({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connector between this column and the next */}
|
{/* Connector between this column and the next */}
|
||||||
{nextRound && (
|
{vi < visible.length - 1 && (
|
||||||
<ConnectorColumn
|
<ConnectorColumn
|
||||||
currentMatches={roundMatches}
|
edges={gutterEdges}
|
||||||
nextMatches={nextMatches}
|
rowHeight={rowHeight}
|
||||||
|
offset={offset}
|
||||||
bracketHeight={bracketHeight}
|
bracketHeight={bracketHeight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -396,6 +493,8 @@ interface BracketTreeViewProps {
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
thirdPlaceRound?: string;
|
thirdPlaceRound?: string;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreeView({
|
export function BracketTreeView({
|
||||||
|
|
@ -404,13 +503,19 @@ export function BracketTreeView({
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
thirdPlaceRound,
|
thirdPlaceRound,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: BracketTreeViewProps) {
|
}: BracketTreeViewProps) {
|
||||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||||
|
|
||||||
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
const geometry = bracketGeometry(
|
||||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
mainRounds,
|
||||||
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
matchesByRound,
|
||||||
|
feeders,
|
||||||
|
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||||
|
);
|
||||||
|
const { bracketHeight, minWidth } = geometry;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -419,11 +524,11 @@ export function BracketTreeView({
|
||||||
>
|
>
|
||||||
<div style={{ minWidth }}>
|
<div style={{ minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={mainRounds}
|
geometry={geometry}
|
||||||
matchesByRound={matchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={bracketHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
{thirdPlaceMatch && (
|
{thirdPlaceMatch && (
|
||||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||||
|
|
@ -441,6 +546,8 @@ export function BracketTreeView({
|
||||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import type { FeederMap } from "~/lib/bracket-layout";
|
||||||
|
import {
|
||||||
|
TreeColumns,
|
||||||
|
bracketGeometry,
|
||||||
|
type BracketMatch,
|
||||||
|
type BracketOwnership,
|
||||||
|
} from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
|
|
||||||
interface NbaBracketLayoutProps {
|
interface NbaBracketLayoutProps {
|
||||||
|
|
@ -10,11 +16,10 @@ interface NbaBracketLayoutProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
conferenceGroups: ConferenceGroup[];
|
conferenceGroups: ConferenceGroup[];
|
||||||
scoringRoundIdx: number;
|
scoringRoundIdx: number;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESIRED_CARD_HEIGHT = 112;
|
|
||||||
const CARD_GAP = 14;
|
|
||||||
|
|
||||||
function splitMatchesByConference(
|
function splitMatchesByConference(
|
||||||
matchesByRound: Map<string, BracketMatch[]>,
|
matchesByRound: Map<string, BracketMatch[]>,
|
||||||
group: ConferenceGroup
|
group: ConferenceGroup
|
||||||
|
|
@ -28,11 +33,6 @@ function splitMatchesByConference(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
|
||||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
|
||||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NbaBracketLayout({
|
export function NbaBracketLayout({
|
||||||
rounds,
|
rounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
|
|
@ -40,7 +40,10 @@ export function NbaBracketLayout({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
conferenceGroups,
|
conferenceGroups,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: NbaBracketLayoutProps) {
|
}: NbaBracketLayoutProps) {
|
||||||
|
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||||
// Rounds that belong to any conference group
|
// Rounds that belong to any conference group
|
||||||
const conferenceRoundSet = new Set(
|
const conferenceRoundSet = new Set(
|
||||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||||
|
|
@ -57,7 +60,7 @@ export function NbaBracketLayout({
|
||||||
const sharedMatches = new Map(
|
const sharedMatches = new Map(
|
||||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||||
);
|
);
|
||||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -66,7 +69,7 @@ export function NbaBracketLayout({
|
||||||
{conferenceGroups.map((group, gi) => {
|
{conferenceGroups.map((group, gi) => {
|
||||||
const confRounds = conferenceRounds[gi];
|
const confRounds = conferenceRounds[gi];
|
||||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||||
const height = bracketHeight(confMatches, confRounds);
|
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
|
|
@ -74,11 +77,11 @@ export function NbaBracketLayout({
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={confRounds}
|
geometry={geometry}
|
||||||
matchesByRound={confMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={height}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -87,11 +90,11 @@ export function NbaBracketLayout({
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={sharedRounds}
|
geometry={sharedGeometry}
|
||||||
matchesByRound={sharedMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={sharedHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { RankingsRow } from "./RankingsRow";
|
||||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
|
|
@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
||||||
return byRound;
|
return byRound;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* For a standard single-elimination bracket, slot p1 of match N in round R
|
|
||||||
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
|
|
||||||
*/
|
|
||||||
export function buildFeederMap(
|
|
||||||
matchesByRound: Map<string, Match[]>,
|
|
||||||
orderedRounds: string[]
|
|
||||||
): Map<string, { round: string; matchNumber: number }> {
|
|
||||||
const feederMap = new Map<string, { round: string; matchNumber: number }>();
|
|
||||||
|
|
||||||
for (let ri = 1; ri < orderedRounds.length; ri++) {
|
|
||||||
const currentRound = orderedRounds[ri];
|
|
||||||
const prevRound = orderedRounds[ri - 1];
|
|
||||||
const prevMatchNums = new Set(
|
|
||||||
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
|
|
||||||
);
|
|
||||||
for (const match of matchesByRound.get(currentRound) || []) {
|
|
||||||
const p1Src = 2 * (match.matchNumber - 1) + 1;
|
|
||||||
const p2Src = 2 * (match.matchNumber - 1) + 2;
|
|
||||||
if (prevMatchNums.has(p1Src)) {
|
|
||||||
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
|
|
||||||
round: prevRound,
|
|
||||||
matchNumber: p1Src,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (prevMatchNums.has(p2Src)) {
|
|
||||||
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
|
|
||||||
round: prevRound,
|
|
||||||
matchNumber: p2Src,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return feederMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EliminatedEntry {
|
interface EliminatedEntry {
|
||||||
participant: Participant;
|
participant: Participant;
|
||||||
score: string | null;
|
score: string | null;
|
||||||
|
|
@ -391,6 +355,8 @@ export function PlayoffBracket({
|
||||||
const matchesByRound = groupMatchesByRound(matches);
|
const matchesByRound = groupMatchesByRound(matches);
|
||||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||||
|
// What fills each slot, used for both card placement and naming empty slots.
|
||||||
|
const feeders = buildFeederMap(template);
|
||||||
|
|
||||||
const consolation = findConsolationRound(template);
|
const consolation = findConsolationRound(template);
|
||||||
const thirdPlaceRound = consolation?.round;
|
const thirdPlaceRound = consolation?.round;
|
||||||
|
|
@ -478,6 +444,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
phases={template.phases}
|
phases={template.phases}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
) : template?.conferenceGroups ? (
|
) : template?.conferenceGroups ? (
|
||||||
<NbaBracketLayout
|
<NbaBracketLayout
|
||||||
|
|
@ -488,6 +456,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
conferenceGroups={template.conferenceGroups}
|
conferenceGroups={template.conferenceGroups}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -499,6 +469,8 @@ export function PlayoffBracket({
|
||||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -511,6 +483,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import type { FeederMap } from "~/lib/bracket-layout";
|
||||||
|
import {
|
||||||
|
TreeColumns,
|
||||||
|
BracketMatchSlot,
|
||||||
|
bracketGeometry,
|
||||||
|
type BracketMatch,
|
||||||
|
type BracketOwnership,
|
||||||
|
} from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
|
|
||||||
|
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
|
||||||
|
const CARD_H = 112;
|
||||||
|
|
||||||
interface TabbedBracketLayoutProps {
|
interface TabbedBracketLayoutProps {
|
||||||
rounds: string[];
|
rounds: string[];
|
||||||
matchesByRound: Map<string, BracketMatch[]>;
|
matchesByRound: Map<string, BracketMatch[]>;
|
||||||
|
|
@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
phases: BracketPhase[];
|
phases: BracketPhase[];
|
||||||
scoringRoundIdx: number;
|
scoringRoundIdx: number;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARD_H = 112;
|
|
||||||
const CARD_GAP = 14;
|
|
||||||
|
|
||||||
function groupMatches(
|
function groupMatches(
|
||||||
matchesByRound: Map<string, BracketMatch[]>,
|
matchesByRound: Map<string, BracketMatch[]>,
|
||||||
group: ConferenceGroup
|
group: ConferenceGroup
|
||||||
|
|
@ -29,11 +38,6 @@ function groupMatches(
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
|
||||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
|
||||||
return max * (CARD_H + CARD_GAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PlayInColumnProps {
|
interface PlayInColumnProps {
|
||||||
|
|
@ -141,7 +145,10 @@ export function TabbedBracketLayout({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
phases,
|
phases,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: TabbedBracketLayoutProps) {
|
}: TabbedBracketLayoutProps) {
|
||||||
|
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{phases.map((phase) => {
|
{phases.map((phase) => {
|
||||||
|
|
@ -194,50 +201,87 @@ export function TabbedBracketLayout({
|
||||||
{phase.groups.map((group) => {
|
{phase.groups.map((group) => {
|
||||||
const gMatches = groupMatches(matchesByRound, group);
|
const gMatches = groupMatches(matchesByRound, group);
|
||||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||||
|
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div style={{ minWidth: geometry.minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={gRounds}
|
geometry={geometry}
|
||||||
matchesByRound={gMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={sharedRounds}
|
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||||
matchesByRound={sharedMatchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={phaseRounds}
|
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||||
matchesByRound={phaseMatchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile */}
|
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
||||||
<div className="md:hidden">
|
whole phase would merge the winners and elimination brackets into one
|
||||||
|
tree, and a double-elimination phase is a DAG rather than a tree: the
|
||||||
|
same game feeds forward and sideways, so its column placement would be
|
||||||
|
arbitrary. */}
|
||||||
|
<div className="md:hidden space-y-6">
|
||||||
{phase.layout === "play-in" ? (
|
{phase.layout === "play-in" ? (
|
||||||
<PlayInLayout
|
<PlayInLayout
|
||||||
matchesByRound={phaseMatchesByRound}
|
matchesByRound={phaseMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
/>
|
/>
|
||||||
|
) : phase.groups ? (
|
||||||
|
<>
|
||||||
|
{phase.groups.map((group) => (
|
||||||
|
<div key={group.name}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
|
{group.name}
|
||||||
|
</p>
|
||||||
|
<BracketTreePaginated
|
||||||
|
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
|
||||||
|
matchesByRound={groupMatches(matchesByRound, group)}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sharedRounds.length > 0 && (
|
||||||
|
<BracketTreePaginated
|
||||||
|
rounds={sharedRounds}
|
||||||
|
matchesByRound={sharedMatchesByRound}
|
||||||
|
ownershipMap={ownershipMap}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<BracketTreePaginated
|
<BracketTreePaginated
|
||||||
rounds={phaseRounds}
|
rounds={phaseRounds}
|
||||||
|
|
@ -245,6 +289,8 @@ export function TabbedBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
PlayoffBracket,
|
PlayoffBracket,
|
||||||
buildFeederMap,
|
|
||||||
groupMatchesByRound,
|
groupMatchesByRound,
|
||||||
computeEliminatedByRound,
|
computeEliminatedByRound,
|
||||||
computeRankedEntries,
|
computeRankedEntries,
|
||||||
|
|
@ -64,88 +63,72 @@ describe("groupMatchesByRound", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildFeederMap
|
// Rendered LLWS bracket — geometry and empty-slot labels
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildFeederMap", () => {
|
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
||||||
it("returns an empty map when there is only one round", () => {
|
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
||||||
const matches = [makeMatch("Finals", 1)];
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
||||||
expect(map.size).toBe(0);
|
function emptyLlwsMatches(): Match[] {
|
||||||
|
const template = getBracketTemplate("llws_20");
|
||||||
|
const matches: Match[] = [];
|
||||||
|
for (const round of template?.rounds ?? []) {
|
||||||
|
for (let n = 1; n <= round.matchCount; n++) {
|
||||||
|
matches.push({
|
||||||
|
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
|
||||||
|
participant1: null,
|
||||||
|
participant2: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("names empty slots after the game that feeds them", () => {
|
||||||
|
render(
|
||||||
|
<PlayoffBracket
|
||||||
|
matches={emptyLlwsMatches()}
|
||||||
|
rounds={LLWS_ROUNDS}
|
||||||
|
bracketTemplateId="llws_20"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// A winners-bracket loss drops into the elimination bracket — an edge that spans
|
||||||
|
// two separately rendered trees, so the label is the only way to show it.
|
||||||
|
expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
|
it("still shows TBD for a directly seeded slot", () => {
|
||||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
render(
|
||||||
const matches = [
|
<PlayoffBracket
|
||||||
makeMatch("Quarterfinals", 1),
|
matches={emptyLlwsMatches()}
|
||||||
makeMatch("Quarterfinals", 2),
|
rounds={LLWS_ROUNDS}
|
||||||
makeMatch("Quarterfinals", 3),
|
bracketTemplateId="llws_20"
|
||||||
makeMatch("Quarterfinals", 4),
|
/>
|
||||||
makeMatch("Semifinals", 1),
|
);
|
||||||
makeMatch("Semifinals", 2),
|
|
||||||
makeMatch("Finals", 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
// The opening round is seeded, not fed, so it has nothing to name.
|
||||||
|
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
||||||
// SF Match 1, slot p1 ← QF Match 1
|
|
||||||
expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
|
||||||
// SF Match 1, slot p2 ← QF Match 2
|
|
||||||
expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
|
||||||
// SF Match 2, slot p1 ← QF Match 3
|
|
||||||
expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
|
|
||||||
// SF Match 2, slot p2 ← QF Match 4
|
|
||||||
expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps Finals slots to the correct SF matches", () => {
|
it("gives every card the same height, including a lone final", () => {
|
||||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
const { container } = render(
|
||||||
const matches = [
|
<PlayoffBracket
|
||||||
makeMatch("Quarterfinals", 1),
|
matches={emptyLlwsMatches()}
|
||||||
makeMatch("Quarterfinals", 2),
|
rounds={LLWS_ROUNDS}
|
||||||
makeMatch("Quarterfinals", 3),
|
bracketTemplateId="llws_20"
|
||||||
makeMatch("Quarterfinals", 4),
|
/>
|
||||||
makeMatch("Semifinals", 1),
|
);
|
||||||
makeMatch("Semifinals", 2),
|
|
||||||
makeMatch("Finals", 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
const heights = new Set(
|
||||||
|
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
||||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
(el) => el.style.height
|
||||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
)
|
||||||
});
|
);
|
||||||
|
// Previously a one-match column stretched its card to fill the bracket height.
|
||||||
it("does not add an entry when the source match does not exist in the previous round", () => {
|
expect(heights.size).toBe(1);
|
||||||
const rounds = ["Quarterfinals", "Finals"];
|
|
||||||
const matches = [
|
|
||||||
makeMatch("Quarterfinals", 1),
|
|
||||||
makeMatch("Quarterfinals", 2),
|
|
||||||
makeMatch("Finals", 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
|
||||||
|
|
||||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
|
||||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
|
||||||
expect(map.has("Finals:2:p1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
|
|
||||||
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
|
|
||||||
const matches = [
|
|
||||||
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
|
|
||||||
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
|
|
||||||
...[1, 2].map((n) => makeMatch("Semifinals", n)),
|
|
||||||
makeMatch("Finals", 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
|
||||||
|
|
||||||
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
|
|
||||||
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
|
|
||||||
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
|
|
||||||
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
533
app/lib/__tests__/bracket-layout.test.ts
Normal file
533
app/lib/__tests__/bracket-layout.test.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
/**
|
||||||
|
* Bracket layout tests.
|
||||||
|
*
|
||||||
|
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
|
||||||
|
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
|
||||||
|
* with the game that feeds it, so these tests assert column membership, top-to-bottom
|
||||||
|
* order, and vertical alignment — not just that a layout was produced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
LLWS_20,
|
||||||
|
SIMPLE_16,
|
||||||
|
NFL_14,
|
||||||
|
BRACKET_TEMPLATES,
|
||||||
|
getBracketTemplate,
|
||||||
|
type BracketTemplate,
|
||||||
|
type ConferenceGroup,
|
||||||
|
} from "~/lib/bracket-templates";
|
||||||
|
import {
|
||||||
|
buildFeederMap,
|
||||||
|
computeGroupLayout,
|
||||||
|
describeSlotSource,
|
||||||
|
matchKey,
|
||||||
|
type SlotSource,
|
||||||
|
} from "~/lib/bracket-layout";
|
||||||
|
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
|
||||||
|
|
||||||
|
interface TestMatch {
|
||||||
|
round: string;
|
||||||
|
matchNumber: number;
|
||||||
|
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
|
||||||
|
winnerId?: string | null;
|
||||||
|
participant1Id?: string | null;
|
||||||
|
participant2Id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every match a template defines, as the renderer would receive them. */
|
||||||
|
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
|
||||||
|
const byRound = new Map<string, TestMatch[]>();
|
||||||
|
for (const round of template.rounds) {
|
||||||
|
byRound.set(
|
||||||
|
round.name,
|
||||||
|
Array.from({ length: round.matchCount }, (_, i) => ({
|
||||||
|
round: round.name,
|
||||||
|
matchNumber: i + 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return byRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */
|
||||||
|
function groupMatches(group: ConferenceGroup): Map<string, TestMatch[]> {
|
||||||
|
const byRound = new Map<string, TestMatch[]>();
|
||||||
|
for (const [round, nums] of Object.entries(group.roundMatchNumbers)) {
|
||||||
|
byRound.set(
|
||||||
|
round,
|
||||||
|
nums.map((matchNumber) => ({ round, matchNumber }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return byRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGroup(name: string): ConferenceGroup {
|
||||||
|
for (const phase of LLWS_20.phases ?? []) {
|
||||||
|
for (const group of phase.groups ?? []) {
|
||||||
|
if (group.name === name) return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No LLWS group named ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lay out one LLWS group and describe it in PDF game numbers. */
|
||||||
|
function layOutLLWSGroup(name: string) {
|
||||||
|
const group = findGroup(name);
|
||||||
|
const byRound = groupMatches(group);
|
||||||
|
const roundOrder = LLWS_20.rounds.map((r) => r.name);
|
||||||
|
const rounds = roundOrder.filter((r) => byRound.has(r));
|
||||||
|
|
||||||
|
const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder);
|
||||||
|
|
||||||
|
const game = (m: TestMatch) => {
|
||||||
|
const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`);
|
||||||
|
if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
labels: layout.columns.map((c) => c.label),
|
||||||
|
/** Column contents, top to bottom, as PDF game numbers. */
|
||||||
|
columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))),
|
||||||
|
/** Vertical centre of a game's card, in leaf-row units. */
|
||||||
|
centerOf(gameNumber: number): number {
|
||||||
|
const target = GAME_TO_MATCH[gameNumber];
|
||||||
|
for (const column of layout.columns) {
|
||||||
|
for (const { match, center } of column.matches) {
|
||||||
|
if (match.round === target.round && match.matchNumber === target.matchNumber) {
|
||||||
|
return center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`G${gameNumber} is not in this group`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeGroupLayout — LLWS winners brackets", () => {
|
||||||
|
// The International side is the one in the reported screenshot. Under the old index
|
||||||
|
// math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go
|
||||||
|
// straight to the semifinals, so nothing in column two lined up with them.
|
||||||
|
it("puts the International winners bracket in the printed bracket's columns", () => {
|
||||||
|
const { columns } = layOutLLWSGroup("International Winner's Bracket");
|
||||||
|
expect(columns).toEqual([
|
||||||
|
[1, 3],
|
||||||
|
[5, 9, 11, 7],
|
||||||
|
[18, 20],
|
||||||
|
[29],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors that layout on the U.S. side", () => {
|
||||||
|
const { columns } = layOutLLWSGroup("U.S. Winner's Bracket");
|
||||||
|
expect(columns).toEqual([
|
||||||
|
[2, 4],
|
||||||
|
[6, 10, 12, 8],
|
||||||
|
[17, 19],
|
||||||
|
[30],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a mixed column for the latest round it holds", () => {
|
||||||
|
// Column two holds two Opening Round games (G5, G7) alongside Winners Round 2.
|
||||||
|
const { labels } = layOutLLWSGroup("International Winner's Bracket");
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"Opening Round",
|
||||||
|
"Winners Round 2",
|
||||||
|
"Winners Semifinals",
|
||||||
|
"Winners Final",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("levels each card with the game that feeds it", () => {
|
||||||
|
const { centerOf } = layOutLLWSGroup("International Winner's Bracket");
|
||||||
|
|
||||||
|
// G1's winner fills a slot of G9, so the two sit at the same height.
|
||||||
|
expect(centerOf(1)).toBe(centerOf(9));
|
||||||
|
expect(centerOf(3)).toBe(centerOf(11));
|
||||||
|
|
||||||
|
// G18 = W5 v W9, so it sits midway between them.
|
||||||
|
expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2);
|
||||||
|
expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2);
|
||||||
|
expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws an edge for every in-group feed, played or not", () => {
|
||||||
|
const { layout } = layOutLLWSGroup("International Winner's Bracket");
|
||||||
|
// G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges.
|
||||||
|
expect(layout.edges).toHaveLength(8);
|
||||||
|
// Every edge crosses exactly one gutter, which is what makes them drawable.
|
||||||
|
for (const edge of layout.edges) {
|
||||||
|
expect(edge.fromColumn).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeGroupLayout — LLWS elimination brackets", () => {
|
||||||
|
it("orders Elimination Round 3 the way the printed bracket does", () => {
|
||||||
|
// G31 = W27 v W25, so the later game is printed on top — the reverse of match
|
||||||
|
// number order, which is how the old index-based sort got it wrong.
|
||||||
|
const { columns } = layOutLLWSGroup("International Elimination Bracket");
|
||||||
|
expect(columns).toEqual([
|
||||||
|
[13, 15],
|
||||||
|
[21, 23],
|
||||||
|
[27, 25],
|
||||||
|
[31],
|
||||||
|
[33],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders the U.S. elimination bracket the same way", () => {
|
||||||
|
const { columns } = layOutLLWSGroup("U.S. Elimination Bracket");
|
||||||
|
expect(columns).toEqual([
|
||||||
|
[14, 16],
|
||||||
|
[22, 24],
|
||||||
|
[28, 26],
|
||||||
|
[32],
|
||||||
|
[34],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores feeds arriving from the winners bracket", () => {
|
||||||
|
// G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here.
|
||||||
|
const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket");
|
||||||
|
expect(centerOf(21)).toBe(centerOf(13));
|
||||||
|
expect(layout.edges).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFeederMap", () => {
|
||||||
|
it("routes LLWS winners and losers to the slots the printed bracket shows", () => {
|
||||||
|
const feeders = buildFeederMap(LLWS_20);
|
||||||
|
|
||||||
|
// G18 = W5 v W9.
|
||||||
|
const g18 = GAME_TO_MATCH[18];
|
||||||
|
expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([
|
||||||
|
{ kind: "match", ref: GAME_TO_MATCH[5], result: "winner" },
|
||||||
|
{ kind: "match", ref: GAME_TO_MATCH[9], result: "winner" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket.
|
||||||
|
const g13 = GAME_TO_MATCH[13];
|
||||||
|
expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([
|
||||||
|
{ kind: "match", ref: GAME_TO_MATCH[3], result: "loser" },
|
||||||
|
{ kind: "match", ref: GAME_TO_MATCH[5], result: "loser" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks directly seeded slots as seeds", () => {
|
||||||
|
const feeders = buildFeederMap(LLWS_20);
|
||||||
|
// G9 = a bye team v W1: slot one is seeded, slot two is fed.
|
||||||
|
const g9 = GAME_TO_MATCH[9];
|
||||||
|
const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? [];
|
||||||
|
expect(p1).toEqual({ kind: "seed" });
|
||||||
|
expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the standard halving rule to other templates", () => {
|
||||||
|
const feeders = buildFeederMap(SIMPLE_16);
|
||||||
|
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||||
|
{ kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" },
|
||||||
|
{ kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" },
|
||||||
|
]);
|
||||||
|
expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([
|
||||||
|
{ kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" },
|
||||||
|
{ kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" },
|
||||||
|
]);
|
||||||
|
// The first round is seeded, not fed.
|
||||||
|
expect(feeders.get(matchKey("Round of 16", 1))).toEqual([
|
||||||
|
{ kind: "seed" },
|
||||||
|
{ kind: "seed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty map without a template", () => {
|
||||||
|
expect(buildFeederMap(undefined).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeGroupLayout — standard brackets are unchanged", () => {
|
||||||
|
it("halves a 16-team bracket evenly, first round in seeded order", () => {
|
||||||
|
const byRound = allMatches(SIMPLE_16);
|
||||||
|
const roundOrder = SIMPLE_16.rounds.map((r) => r.name);
|
||||||
|
const layout = computeGroupLayout(
|
||||||
|
roundOrder,
|
||||||
|
byRound,
|
||||||
|
buildFeederMap(SIMPLE_16),
|
||||||
|
roundOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(layout.leafCount).toBe(8);
|
||||||
|
expect(layout.columns.map((c) => c.label)).toEqual(roundOrder);
|
||||||
|
expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([
|
||||||
|
[1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
[1, 2],
|
||||||
|
[1],
|
||||||
|
]);
|
||||||
|
// Evenly spread, exactly as the previous index math placed them.
|
||||||
|
expect(layout.columns[0].matches.map((m) => m.center)).toEqual([
|
||||||
|
0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5,
|
||||||
|
]);
|
||||||
|
expect(layout.columns[3].matches[0].center).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles byes, placing a seeded team level with the round it enters", () => {
|
||||||
|
// The NFL bracket's top seeds skip the wild card round.
|
||||||
|
const byRound = allMatches(NFL_14);
|
||||||
|
const roundOrder = NFL_14.rounds.map((r) => r.name);
|
||||||
|
const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder);
|
||||||
|
expect(layout.columns.length).toBeGreaterThan(0);
|
||||||
|
for (const column of layout.columns) {
|
||||||
|
expect(column.matches.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to even spreading when a group has no single root", () => {
|
||||||
|
// Two finals and no way to join them — the shape can't resolve to one tree.
|
||||||
|
const byRound = new Map<string, TestMatch[]>([
|
||||||
|
["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
|
||||||
|
[
|
||||||
|
"Finals",
|
||||||
|
[
|
||||||
|
{ round: "Finals", matchNumber: 1 },
|
||||||
|
{ round: "Finals", matchNumber: 2 },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const layout = computeGroupLayout(
|
||||||
|
["Semifinals", "Finals"],
|
||||||
|
byRound,
|
||||||
|
new Map(),
|
||||||
|
["Semifinals", "Finals"]
|
||||||
|
);
|
||||||
|
expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
|
||||||
|
expect(layout.edges).toEqual([]);
|
||||||
|
expect(layout.columns[0].matches[0].center).toBe(1);
|
||||||
|
expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an empty group", () => {
|
||||||
|
const layout = computeGroupLayout([], new Map(), new Map(), []);
|
||||||
|
expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFeederMap — templates with routing of their own", () => {
|
||||||
|
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
|
||||||
|
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
|
||||||
|
// is worse than drawing nothing.
|
||||||
|
|
||||||
|
it("follows feedsInto rather than the order rounds are listed in", () => {
|
||||||
|
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
|
||||||
|
// next to it, so array order would fabricate the whole chain.
|
||||||
|
const afl = BRACKET_TEMPLATES.afl_10;
|
||||||
|
const feeders = buildFeederMap(afl);
|
||||||
|
const fed = [...feeders.entries()].filter(([, pair]) =>
|
||||||
|
pair.some((s) => s.kind === "match")
|
||||||
|
);
|
||||||
|
// Only Preliminary Finals → Grand Final actually halves.
|
||||||
|
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
|
||||||
|
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
|
||||||
|
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
|
||||||
|
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||||
|
{ kind: "seed" },
|
||||||
|
{ kind: "seed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the First Four out of the Round of 64", () => {
|
||||||
|
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
|
||||||
|
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
|
||||||
|
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
|
||||||
|
{ kind: "seed" },
|
||||||
|
{ kind: "seed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
|
||||||
|
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
|
||||||
|
// halve but the winners-only rule still doesn't describe it.
|
||||||
|
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
|
||||||
|
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
|
||||||
|
{ kind: "seed" },
|
||||||
|
{ kind: "seed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not route the FIFA final through the third place game", () => {
|
||||||
|
// Third Place Game sits between Semifinals and Finals in round order, so array
|
||||||
|
// order made it the Finals' feeder and left the Finals' second slot empty.
|
||||||
|
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
|
||||||
|
expect(feeders.get(matchKey("Finals", 1))).toEqual([
|
||||||
|
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
|
||||||
|
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeGroupLayout — every template still draws connectors", () => {
|
||||||
|
/** Lay a whole template out the way BracketTreeView would. */
|
||||||
|
function layOut(template: BracketTemplate) {
|
||||||
|
const byRound = allMatches(template);
|
||||||
|
const order = template.rounds.map((r) => r.name);
|
||||||
|
// BracketTreeView renders a third place game outside the tree.
|
||||||
|
const rounds = order.filter((r) => r !== "Third Place Game");
|
||||||
|
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gutter joining a column to one exactly half its size is a plain bracket join and
|
||||||
|
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
|
||||||
|
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
|
||||||
|
// which is what these brackets did before.
|
||||||
|
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
|
||||||
|
"%s draws every gutter that halves",
|
||||||
|
(id) => {
|
||||||
|
const layout = layOut(BRACKET_TEMPLATES[id]);
|
||||||
|
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
|
||||||
|
let halvingGutters = 0;
|
||||||
|
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
|
||||||
|
const from = layout.columns[ci].matches.length;
|
||||||
|
const to = layout.columns[ci + 1].matches.length;
|
||||||
|
if (from !== to * 2) continue;
|
||||||
|
halvingGutters += 1;
|
||||||
|
expect(gutters).toContain(ci);
|
||||||
|
}
|
||||||
|
// Every template has at least one, so a template that lost all its lines fails.
|
||||||
|
expect(halvingGutters).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
|
||||||
|
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
|
||||||
|
expect(layout.columns.map((c) => c.label)).toEqual([
|
||||||
|
"Round of 32",
|
||||||
|
"Round of 16",
|
||||||
|
"Quarterfinals",
|
||||||
|
"Semifinals",
|
||||||
|
"Finals",
|
||||||
|
]);
|
||||||
|
expect(layout.edges).toHaveLength(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
|
||||||
|
// it renders per side, which the tests further up cover.
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeGroupLayout — fallback keeps the old connectors", () => {
|
||||||
|
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
|
const byRound = new Map<string, TestMatch[]>([
|
||||||
|
["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))],
|
||||||
|
["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))],
|
||||||
|
["Finals", [{ round: "Finals", matchNumber: 1 }]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("infers halving edges when there is no feeder map at all", () => {
|
||||||
|
// A bracket with no template id, which SportSeasonDisplay renders.
|
||||||
|
const layout = computeGroupLayout(rounds, byRound, new Map(), rounds);
|
||||||
|
expect(layout.edges).toHaveLength(6);
|
||||||
|
// Quarterfinals 1 and 2 both join Semifinal 1.
|
||||||
|
const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1);
|
||||||
|
expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traces played winners when the shape is not a halving", () => {
|
||||||
|
const irregular = new Map<string, TestMatch[]>([
|
||||||
|
[
|
||||||
|
"Wildcard",
|
||||||
|
[
|
||||||
|
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
|
||||||
|
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Semifinals",
|
||||||
|
[
|
||||||
|
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
|
||||||
|
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const layout = computeGroupLayout(
|
||||||
|
["Wildcard", "Semifinals"],
|
||||||
|
irregular,
|
||||||
|
new Map(),
|
||||||
|
["Wildcard", "Semifinals"]
|
||||||
|
);
|
||||||
|
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
|
||||||
|
// edge that only the actual result can reveal.
|
||||||
|
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
|
||||||
|
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeSlotSource", () => {
|
||||||
|
const feeders = buildFeederMap(LLWS_20);
|
||||||
|
const sourcesFor = (game: number): [SlotSource, SlotSource] => {
|
||||||
|
const m = GAME_TO_MATCH[game];
|
||||||
|
const pair = feeders.get(matchKey(m.round, m.matchNumber));
|
||||||
|
if (!pair) throw new Error(`No feeders for G${game}`);
|
||||||
|
return pair;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("names a winner feed", () => {
|
||||||
|
// G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
|
||||||
|
expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a loser feed, which is the one no line can show", () => {
|
||||||
|
// G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
|
||||||
|
expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
|
||||||
|
// G25 = L18 v W23; G18 is International semifinal 1.
|
||||||
|
expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses side-local numbers, as the printed bracket does", () => {
|
||||||
|
// G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
|
||||||
|
// semifinal 2 — the number the printed bracket uses.
|
||||||
|
expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the side where each side plays only one such game", () => {
|
||||||
|
// G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
|
||||||
|
// number would say nothing and the side is the only thing that tells them apart.
|
||||||
|
const [p1, p2] = sourcesFor(37);
|
||||||
|
expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
|
||||||
|
expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
|
||||||
|
|
||||||
|
// Same rule inside a side bracket: G34 = L30 v W32.
|
||||||
|
const [elimP1, elimP2] = sourcesFor(34);
|
||||||
|
expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
|
||||||
|
expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops both number and side for the shared final games", () => {
|
||||||
|
// The two sides meet here, so there is only one of each game in the whole bracket.
|
||||||
|
const wc = describeSlotSource(
|
||||||
|
{ kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
|
||||||
|
LLWS_20
|
||||||
|
);
|
||||||
|
expect(wc).toBe("Winner of World Championship");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a seeded slot so the caller can render TBD", () => {
|
||||||
|
expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
|
||||||
|
expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses plain round names for non-LLWS templates", () => {
|
||||||
|
const template = getBracketTemplate("simple_16");
|
||||||
|
const source: SlotSource = {
|
||||||
|
kind: "match",
|
||||||
|
ref: { round: "Quarterfinals", matchNumber: 3 },
|
||||||
|
result: "winner",
|
||||||
|
};
|
||||||
|
expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
|
||||||
|
});
|
||||||
|
});
|
||||||
420
app/lib/bracket-layout.ts
Normal file
420
app/lib/bracket-layout.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
/**
|
||||||
|
* 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<string, [SlotSource, SlotSource]>;
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
"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<M> {
|
||||||
|
match: M;
|
||||||
|
/** Centre of the card, in slot units (1 unit = one leaf row). */
|
||||||
|
center: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutColumn<M> {
|
||||||
|
label: string;
|
||||||
|
matches: LaidOutMatch<M>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BracketLayout<M> {
|
||||||
|
columns: LayoutColumn<M>[];
|
||||||
|
/** 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<M extends PositionedMatch>(
|
||||||
|
visibleRounds: string[],
|
||||||
|
matchesByRound: Map<string, M[]>,
|
||||||
|
feeders: FeederMap,
|
||||||
|
templateRoundOrder: string[]
|
||||||
|
): BracketLayout<M> {
|
||||||
|
const nodes = new Map<string, M>();
|
||||||
|
const roundOf = new Map<string, string>();
|
||||||
|
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<string, string[]>();
|
||||||
|
const hasParent = new Set<string>();
|
||||||
|
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<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
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<M>[] = 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<M>["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<M extends PositionedMatch>(
|
||||||
|
visibleRounds: string[],
|
||||||
|
matchesByRound: Map<string, M[]>
|
||||||
|
): BracketLayout<M> {
|
||||||
|
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<M>["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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
194
app/lib/llws-bracket.ts
Normal file
194
app/lib/llws-bracket.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/**
|
||||||
|
* LLWS 20-team double-elimination routing — the pure half of the bracket.
|
||||||
|
*
|
||||||
|
* Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match
|
||||||
|
* pulls in the database context and drizzle, which must not reach the browser bundle.
|
||||||
|
* models/playoff-match re-exports everything here, so server-side callers are unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
||||||
|
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
||||||
|
*/
|
||||||
|
interface LLWSDestination {
|
||||||
|
round: string;
|
||||||
|
localMatch: number;
|
||||||
|
slot: "participant1Id" | "participant2Id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
||||||
|
*
|
||||||
|
* Keyed by round, then by the local match number of the completed game. Each entry
|
||||||
|
* says where the winner goes and where the loser goes (null = eliminated).
|
||||||
|
*
|
||||||
|
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
||||||
|
* cross-overs — the elimination bracket does NOT feed straight across:
|
||||||
|
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
||||||
|
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
||||||
|
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
||||||
|
*
|
||||||
|
* A loss in the winners bracket routes into the elimination bracket rather than
|
||||||
|
* eliminating the team; a loss in the elimination bracket is final.
|
||||||
|
*/
|
||||||
|
const LLWS_ADVANCEMENT: Record<
|
||||||
|
string,
|
||||||
|
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||||||
|
> = {
|
||||||
|
"Opening Round": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Round 2": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Semifinals": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Final": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
||||||
|
// A winners-bracket final loss is not an elimination — it drops to the
|
||||||
|
// Elimination Final for a second chance at the side championship.
|
||||||
|
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 1": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 2": {
|
||||||
|
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 3": {
|
||||||
|
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 4": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Final": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
||||||
|
export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
||||||
|
"Opening Round",
|
||||||
|
"Winners Round 2",
|
||||||
|
"Winners Semifinals",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
||||||
|
export interface LLWSResolvedDestination {
|
||||||
|
round: string;
|
||||||
|
matchNumber: number;
|
||||||
|
slot: "participant1Id" | "participant2Id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
||||||
|
* numbers. `null` means that participant has no further game (eliminated, or the
|
||||||
|
* tournament is over for them).
|
||||||
|
*
|
||||||
|
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
||||||
|
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
||||||
|
*/
|
||||||
|
export function resolveLLWSAdvancement(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number
|
||||||
|
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
||||||
|
// Terminal rounds — nobody advances.
|
||||||
|
if (round === "Consolation Third Place" || round === "World Championship") {
|
||||||
|
return { winner: null, loser: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bracket Championship is the crossover: the winner goes to the World Championship
|
||||||
|
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
||||||
|
// participant1, International participant2), so the two sides can't collide.
|
||||||
|
if (round === "Bracket Championship") {
|
||||||
|
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
||||||
|
const slot: "participant1Id" | "participant2Id" =
|
||||||
|
side === 0 ? "participant1Id" : "participant2Id";
|
||||||
|
return {
|
||||||
|
winner: { round: "World Championship", matchNumber: 1, slot },
|
||||||
|
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundMap = LLWS_ADVANCEMENT[round];
|
||||||
|
if (!roundMap) {
|
||||||
|
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||||
|
const routes = roundMap[localMatch];
|
||||||
|
if (!routes) {
|
||||||
|
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Winner and loser stay on their own side, so the same side offset applies to both.
|
||||||
|
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
||||||
|
d === null
|
||||||
|
? null
|
||||||
|
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
||||||
|
|
||||||
|
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,13 @@ import {
|
||||||
calculateAveragedPoints,
|
calculateAveragedPoints,
|
||||||
type ScoringRules,
|
type ScoringRules,
|
||||||
} from "../scoring-rules";
|
} from "../scoring-rules";
|
||||||
|
import {
|
||||||
|
GAME_TO_MATCH,
|
||||||
|
EXPECTED_SLOTS,
|
||||||
|
gameNumberFor,
|
||||||
|
required,
|
||||||
|
destinationGame,
|
||||||
|
} from "~/test/fixtures/llws-bracket";
|
||||||
|
|
||||||
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
||||||
// minimal stub is enough to capture the generated rows.
|
// minimal stub is enough to capture the generated rows.
|
||||||
|
|
@ -55,121 +62,6 @@ const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor8th: 10,
|
pointsFor8th: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
|
|
||||||
//
|
|
||||||
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
|
|
||||||
// numbers in each round, International the high ones.
|
|
||||||
const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
|
|
||||||
// Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
|
|
||||||
2: { round: "Opening Round", matchNumber: 1 },
|
|
||||||
4: { round: "Opening Round", matchNumber: 2 },
|
|
||||||
6: { round: "Opening Round", matchNumber: 3 },
|
|
||||||
8: { round: "Opening Round", matchNumber: 4 },
|
|
||||||
1: { round: "Opening Round", matchNumber: 5 },
|
|
||||||
3: { round: "Opening Round", matchNumber: 6 },
|
|
||||||
5: { round: "Opening Round", matchNumber: 7 },
|
|
||||||
7: { round: "Opening Round", matchNumber: 8 },
|
|
||||||
// Winners Round 2 — U.S. G10,12; Intl G9,11
|
|
||||||
10: { round: "Winners Round 2", matchNumber: 1 },
|
|
||||||
12: { round: "Winners Round 2", matchNumber: 2 },
|
|
||||||
9: { round: "Winners Round 2", matchNumber: 3 },
|
|
||||||
11: { round: "Winners Round 2", matchNumber: 4 },
|
|
||||||
// Elimination Round 1 — U.S. G14,16; Intl G13,15
|
|
||||||
14: { round: "Elimination Round 1", matchNumber: 1 },
|
|
||||||
16: { round: "Elimination Round 1", matchNumber: 2 },
|
|
||||||
13: { round: "Elimination Round 1", matchNumber: 3 },
|
|
||||||
15: { round: "Elimination Round 1", matchNumber: 4 },
|
|
||||||
// Winners Semifinals — U.S. G17,19; Intl G18,20
|
|
||||||
17: { round: "Winners Semifinals", matchNumber: 1 },
|
|
||||||
19: { round: "Winners Semifinals", matchNumber: 2 },
|
|
||||||
18: { round: "Winners Semifinals", matchNumber: 3 },
|
|
||||||
20: { round: "Winners Semifinals", matchNumber: 4 },
|
|
||||||
// Elimination Round 2 — U.S. G22,24; Intl G21,23
|
|
||||||
22: { round: "Elimination Round 2", matchNumber: 1 },
|
|
||||||
24: { round: "Elimination Round 2", matchNumber: 2 },
|
|
||||||
21: { round: "Elimination Round 2", matchNumber: 3 },
|
|
||||||
23: { round: "Elimination Round 2", matchNumber: 4 },
|
|
||||||
// Elimination Round 3 — U.S. G26,28; Intl G25,27
|
|
||||||
26: { round: "Elimination Round 3", matchNumber: 1 },
|
|
||||||
28: { round: "Elimination Round 3", matchNumber: 2 },
|
|
||||||
25: { round: "Elimination Round 3", matchNumber: 3 },
|
|
||||||
27: { round: "Elimination Round 3", matchNumber: 4 },
|
|
||||||
// Winners Final — U.S. G30; Intl G29
|
|
||||||
30: { round: "Winners Final", matchNumber: 1 },
|
|
||||||
29: { round: "Winners Final", matchNumber: 2 },
|
|
||||||
// Elimination Round 4 — U.S. G32; Intl G31
|
|
||||||
32: { round: "Elimination Round 4", matchNumber: 1 },
|
|
||||||
31: { round: "Elimination Round 4", matchNumber: 2 },
|
|
||||||
// Elimination Final — U.S. G34; Intl G33
|
|
||||||
34: { round: "Elimination Final", matchNumber: 1 },
|
|
||||||
33: { round: "Elimination Final", matchNumber: 2 },
|
|
||||||
// Bracket Championship — U.S. G36; Intl G35
|
|
||||||
36: { round: "Bracket Championship", matchNumber: 1 },
|
|
||||||
35: { round: "Bracket Championship", matchNumber: 2 },
|
|
||||||
// Finals
|
|
||||||
37: { round: "Consolation Third Place", matchNumber: 1 },
|
|
||||||
38: { round: "World Championship", matchNumber: 1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const MATCH_TO_GAME = new Map<string, number>(
|
|
||||||
Object.entries(GAME_TO_MATCH).map(([game, m]) => [
|
|
||||||
`${m.round}#${m.matchNumber}`,
|
|
||||||
Number(game),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
function gameNumberFor(round: string, matchNumber: number): number {
|
|
||||||
const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
|
|
||||||
if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
|
|
||||||
return game;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Narrows a destination that the test expects to exist. */
|
|
||||||
function required<T>(destination: T | null): T {
|
|
||||||
if (destination === null) throw new Error("Expected a destination, got null");
|
|
||||||
return destination;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PDF game number a destination points at. */
|
|
||||||
function destinationGame(
|
|
||||||
destination: { round: string; matchNumber: number } | null
|
|
||||||
): number {
|
|
||||||
const d = required(destination);
|
|
||||||
return gameNumberFor(d.round, d.matchNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The official bracket printed as feed labels: for each game, which prior game's
|
|
||||||
* winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
|
|
||||||
*
|
|
||||||
* Transcribed from the PDF. This is the source of truth the routing must reproduce.
|
|
||||||
*/
|
|
||||||
const EXPECTED_SLOTS: Record<number, [string | null, string | null]> = {
|
|
||||||
// Opening Round — all directly seeded
|
|
||||||
1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
|
|
||||||
5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
|
|
||||||
// Winners Round 2 — bye team, then an Opening Round winner
|
|
||||||
9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
|
|
||||||
// Elimination Round 1
|
|
||||||
13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
|
|
||||||
// Winners Semifinals
|
|
||||||
17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
|
|
||||||
// Elimination Round 2
|
|
||||||
21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
|
|
||||||
// Elimination Round 3 — cross-over
|
|
||||||
25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
|
|
||||||
// Winners Final
|
|
||||||
29: ["W18", "W20"], 30: ["W17", "W19"],
|
|
||||||
// Elimination Round 4
|
|
||||||
31: ["W27", "W25"], 32: ["W28", "W26"],
|
|
||||||
// Elimination Final
|
|
||||||
33: ["L29", "W31"], 34: ["L30", "W32"],
|
|
||||||
// Bracket Championship
|
|
||||||
35: ["W29", "W33"], 36: ["W30", "W34"],
|
|
||||||
// Finals
|
|
||||||
37: ["L36", "L35"], 38: ["W36", "W35"],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("LLWS 20 Bracket Template", () => {
|
describe("LLWS 20 Bracket Template", () => {
|
||||||
describe("Template structure", () => {
|
describe("Template structure", () => {
|
||||||
it("has correct identity and size", () => {
|
it("has correct identity and size", () => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ import {
|
||||||
llwsSideAndLocal,
|
llwsSideAndLocal,
|
||||||
STANDARD_BRACKET_SEEDING,
|
STANDARD_BRACKET_SEEDING,
|
||||||
} from "~/lib/bracket-templates";
|
} from "~/lib/bracket-templates";
|
||||||
|
import {
|
||||||
|
LLWS_LOSER_ADVANCES_ROUNDS,
|
||||||
|
resolveLLWSAdvancement,
|
||||||
|
type LLWSResolvedDestination,
|
||||||
|
} from "~/lib/llws-bracket";
|
||||||
|
|
||||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||||
|
|
@ -1563,190 +1568,10 @@ async function advanceNBAPlayInWinner(
|
||||||
|
|
||||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
// The routing table itself is pure and lives in lib/ so the renderer can import it
|
||||||
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
// without pulling the database context into the browser bundle. Re-exported here so
|
||||||
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
// existing server-side callers and tests keep their import path.
|
||||||
*/
|
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
|
||||||
interface LLWSDestination {
|
|
||||||
round: string;
|
|
||||||
localMatch: number;
|
|
||||||
slot: "participant1Id" | "participant2Id";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
|
||||||
*
|
|
||||||
* Keyed by round, then by the local match number of the completed game. Each entry
|
|
||||||
* says where the winner goes and where the loser goes (null = eliminated).
|
|
||||||
*
|
|
||||||
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
|
||||||
* cross-overs — the elimination bracket does NOT feed straight across:
|
|
||||||
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
|
||||||
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
|
||||||
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
|
||||||
*
|
|
||||||
* A loss in the winners bracket routes into the elimination bracket rather than
|
|
||||||
* eliminating the team; a loss in the elimination bracket is final.
|
|
||||||
*/
|
|
||||||
const LLWS_ADVANCEMENT: Record<
|
|
||||||
string,
|
|
||||||
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
|
||||||
> = {
|
|
||||||
"Opening Round": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Winners Round 2": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
|
||||||
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Winners Semifinals": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
|
||||||
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Winners Final": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
|
||||||
// A winners-bracket final loss is not an elimination — it drops to the
|
|
||||||
// Elimination Final for a second chance at the side championship.
|
|
||||||
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 1": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 2": {
|
|
||||||
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 3": {
|
|
||||||
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Round 4": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Elimination Final": {
|
|
||||||
1: {
|
|
||||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
|
||||||
loser: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
|
||||||
const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
|
||||||
"Opening Round",
|
|
||||||
"Winners Round 2",
|
|
||||||
"Winners Semifinals",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
|
||||||
export interface LLWSResolvedDestination {
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
slot: "participant1Id" | "participant2Id";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
|
||||||
* numbers. `null` means that participant has no further game (eliminated, or the
|
|
||||||
* tournament is over for them).
|
|
||||||
*
|
|
||||||
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
|
||||||
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
|
||||||
*/
|
|
||||||
export function resolveLLWSAdvancement(
|
|
||||||
round: string,
|
|
||||||
matchNumber: number
|
|
||||||
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
|
||||||
// Terminal rounds — nobody advances.
|
|
||||||
if (round === "Consolation Third Place" || round === "World Championship") {
|
|
||||||
return { winner: null, loser: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bracket Championship is the crossover: the winner goes to the World Championship
|
|
||||||
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
|
||||||
// participant1, International participant2), so the two sides can't collide.
|
|
||||||
if (round === "Bracket Championship") {
|
|
||||||
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
|
||||||
const slot: "participant1Id" | "participant2Id" =
|
|
||||||
side === 0 ? "participant1Id" : "participant2Id";
|
|
||||||
return {
|
|
||||||
winner: { round: "World Championship", matchNumber: 1, slot },
|
|
||||||
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const roundMap = LLWS_ADVANCEMENT[round];
|
|
||||||
if (!roundMap) {
|
|
||||||
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
|
||||||
const routes = roundMap[localMatch];
|
|
||||||
if (!routes) {
|
|
||||||
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Winner and loser stay on their own side, so the same side offset applies to both.
|
|
||||||
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
|
||||||
d === null
|
|
||||||
? null
|
|
||||||
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
|
||||||
|
|
||||||
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
||||||
|
|
|
||||||
130
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
130
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/**
|
||||||
|
* clear-bracket is the only path that can tear down a bracket, so the guard around it
|
||||||
|
* matters: it discards recorded results and the placements derived from them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
findPlayoffMatchesByEventId,
|
||||||
|
deletePlayoffMatchesByEventId,
|
||||||
|
} from "~/models/playoff-match";
|
||||||
|
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||||
|
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||||
|
import { getScoringEventById } from "~/models/scoring-event";
|
||||||
|
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||||
|
|
||||||
|
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
getScoringEventById: vi.fn(),
|
||||||
|
updateScoringEvent: vi.fn(),
|
||||||
|
isReadOnlySibling: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
findPlayoffMatchesByEventId: vi.fn(),
|
||||||
|
deletePlayoffMatchesByEventId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
recalculateAffectedLeagues: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const EVENT = { id: "event-1", sportsSeasonId: "season-1" };
|
||||||
|
const params = { id: "season-1", eventId: "event-1" };
|
||||||
|
|
||||||
|
function clearRequest(confirm?: string): Request {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("intent", "clear-bracket");
|
||||||
|
if (confirm !== undefined) body.set("confirm", confirm);
|
||||||
|
return new Request("http://localhost/clear", { method: "POST", body });
|
||||||
|
}
|
||||||
|
|
||||||
|
function match(isComplete: boolean) {
|
||||||
|
return { id: `m-${Math.random()}`, isComplete };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The action's real signature carries React Router's generated types; the clear path
|
||||||
|
// only reads request and params.
|
||||||
|
const run = (request: Request) =>
|
||||||
|
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}>)({ request, params });
|
||||||
|
|
||||||
|
describe("clear-bracket", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||||
|
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||||
|
);
|
||||||
|
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the matches", async () => {
|
||||||
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||||
|
match(false),
|
||||||
|
match(false),
|
||||||
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||||
|
|
||||||
|
const result = await run(clearRequest());
|
||||||
|
|
||||||
|
expect(result.success).toContain("2 match(es) removed");
|
||||||
|
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves placements alone — they belong to the whole season, not this event", async () => {
|
||||||
|
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
|
||||||
|
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
|
||||||
|
// the tool that rebuilds them correctly.
|
||||||
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||||
|
match(true),
|
||||||
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||||
|
|
||||||
|
const result = await run(clearRequest("true"));
|
||||||
|
|
||||||
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||||
|
expect(result.success).toContain("Reprocess Bracket");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to discard completed matches without confirmation", async () => {
|
||||||
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||||
|
match(true),
|
||||||
|
match(false),
|
||||||
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||||
|
|
||||||
|
const result = await run(clearRequest());
|
||||||
|
|
||||||
|
expect(result.error).toContain("1 completed match(es)");
|
||||||
|
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards completed matches once confirmed", async () => {
|
||||||
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||||
|
match(true),
|
||||||
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||||
|
|
||||||
|
const result = await run(clearRequest("true"));
|
||||||
|
|
||||||
|
expect(result.success).toBeDefined();
|
||||||
|
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an event with no bracket rather than reporting a no-op success", async () => {
|
||||||
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||||||
|
[] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await run(clearRequest("true"));
|
||||||
|
|
||||||
|
expect(result.error).toContain("no bracket to clear");
|
||||||
|
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
findPlayoffMatchesByEventId,
|
findPlayoffMatchesByEventId,
|
||||||
|
deletePlayoffMatchesByEventId,
|
||||||
generateBracketFromTemplate,
|
generateBracketFromTemplate,
|
||||||
setMatchWinner,
|
setMatchWinner,
|
||||||
advanceWinnerTemplate,
|
advanceWinnerTemplate,
|
||||||
|
|
@ -288,6 +289,50 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
|
||||||
|
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
|
||||||
|
if (intent === "clear-bracket") {
|
||||||
|
try {
|
||||||
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
if (!event) return { error: "Event not found" };
|
||||||
|
|
||||||
|
const existing = await findPlayoffMatchesByEventId(params.eventId);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return { error: "This event has no bracket to clear" };
|
||||||
|
}
|
||||||
|
// Clearing discards recorded results, so make the admin confirm once games have
|
||||||
|
// actually been played.
|
||||||
|
const completed = existing.filter((m) => m.isComplete).length;
|
||||||
|
if (completed > 0 && formData.get("confirm") !== "true") {
|
||||||
|
return {
|
||||||
|
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePlayoffMatchesByEventId(params.eventId);
|
||||||
|
|
||||||
|
// Placements are deliberately left alone. seasonParticipantResults is keyed by
|
||||||
|
// sports season, not by event, so a season-wide delete here would wipe the
|
||||||
|
// placements of every other event in the season with nothing to rebuild them —
|
||||||
|
// and on a finalized qualifying season that means permanently zeroed standings.
|
||||||
|
// Reprocess Bracket already rebuilds placements correctly, qualifying path
|
||||||
|
// included, so point the admin at it once the new bracket is in place.
|
||||||
|
const note =
|
||||||
|
completed > 0
|
||||||
|
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error clearing bracket:", error);
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : "Failed to clear bracket",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "generate-bracket") {
|
if (intent === "generate-bracket") {
|
||||||
const templateId = formData.get("templateId");
|
const templateId = formData.get("templateId");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -613,6 +613,60 @@ export default function EventBracket({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
|
||||||
|
can rewrite a match's participants, so a wrong seeding has to be torn down
|
||||||
|
and rebuilt via the setup form below, which reappears once this runs. */}
|
||||||
|
{matches.length > 0 && (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Clear Bracket</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Delete every match in this bracket so it can be set up again from
|
||||||
|
scratch. Use this when the wrong participants were seeded. Placements
|
||||||
|
are left alone — run Reprocess Bracket after rebuilding to clear any
|
||||||
|
that the discarded results produced.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form
|
||||||
|
method="post"
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="intent" value="clear-bracket" />
|
||||||
|
{/* The server refuses to discard completed matches unless this is
|
||||||
|
checked. Sending it unconditionally from a hidden field would make
|
||||||
|
that guard unreachable, including for a submit without JS. */}
|
||||||
|
{matches.some((m: { isComplete: boolean }) => m.isComplete) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="confirm-clear-bracket"
|
||||||
|
name="confirm"
|
||||||
|
value="true"
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="confirm-clear-bracket" className="font-normal">
|
||||||
|
Yes, discard the results already recorded in this bracket
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" variant="destructive">
|
||||||
|
Clear Bracket
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ====== SETUP PHASE ====== */}
|
{/* ====== SETUP PHASE ====== */}
|
||||||
{showSetup && (
|
{showSetup && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
122
app/test/fixtures/llws-bracket.ts
vendored
Normal file
122
app/test/fixtures/llws-bracket.ts
vendored
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* The official 2026 LLBWS bracket (Williamsport, Aug 19–30), transcribed from the PDF.
|
||||||
|
*
|
||||||
|
* The printed bracket numbers its games 1–38. Both the routing tests and the layout
|
||||||
|
* tests check themselves against these numbers, so the transcription lives here rather
|
||||||
|
* than in either one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
|
||||||
|
// numbers in each round, International the high ones.
|
||||||
|
export const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
|
||||||
|
// Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
|
||||||
|
2: { round: "Opening Round", matchNumber: 1 },
|
||||||
|
4: { round: "Opening Round", matchNumber: 2 },
|
||||||
|
6: { round: "Opening Round", matchNumber: 3 },
|
||||||
|
8: { round: "Opening Round", matchNumber: 4 },
|
||||||
|
1: { round: "Opening Round", matchNumber: 5 },
|
||||||
|
3: { round: "Opening Round", matchNumber: 6 },
|
||||||
|
5: { round: "Opening Round", matchNumber: 7 },
|
||||||
|
7: { round: "Opening Round", matchNumber: 8 },
|
||||||
|
// Winners Round 2 — U.S. G10,12; Intl G9,11
|
||||||
|
10: { round: "Winners Round 2", matchNumber: 1 },
|
||||||
|
12: { round: "Winners Round 2", matchNumber: 2 },
|
||||||
|
9: { round: "Winners Round 2", matchNumber: 3 },
|
||||||
|
11: { round: "Winners Round 2", matchNumber: 4 },
|
||||||
|
// Elimination Round 1 — U.S. G14,16; Intl G13,15
|
||||||
|
14: { round: "Elimination Round 1", matchNumber: 1 },
|
||||||
|
16: { round: "Elimination Round 1", matchNumber: 2 },
|
||||||
|
13: { round: "Elimination Round 1", matchNumber: 3 },
|
||||||
|
15: { round: "Elimination Round 1", matchNumber: 4 },
|
||||||
|
// Winners Semifinals — U.S. G17,19; Intl G18,20
|
||||||
|
17: { round: "Winners Semifinals", matchNumber: 1 },
|
||||||
|
19: { round: "Winners Semifinals", matchNumber: 2 },
|
||||||
|
18: { round: "Winners Semifinals", matchNumber: 3 },
|
||||||
|
20: { round: "Winners Semifinals", matchNumber: 4 },
|
||||||
|
// Elimination Round 2 — U.S. G22,24; Intl G21,23
|
||||||
|
22: { round: "Elimination Round 2", matchNumber: 1 },
|
||||||
|
24: { round: "Elimination Round 2", matchNumber: 2 },
|
||||||
|
21: { round: "Elimination Round 2", matchNumber: 3 },
|
||||||
|
23: { round: "Elimination Round 2", matchNumber: 4 },
|
||||||
|
// Elimination Round 3 — U.S. G26,28; Intl G25,27
|
||||||
|
26: { round: "Elimination Round 3", matchNumber: 1 },
|
||||||
|
28: { round: "Elimination Round 3", matchNumber: 2 },
|
||||||
|
25: { round: "Elimination Round 3", matchNumber: 3 },
|
||||||
|
27: { round: "Elimination Round 3", matchNumber: 4 },
|
||||||
|
// Winners Final — U.S. G30; Intl G29
|
||||||
|
30: { round: "Winners Final", matchNumber: 1 },
|
||||||
|
29: { round: "Winners Final", matchNumber: 2 },
|
||||||
|
// Elimination Round 4 — U.S. G32; Intl G31
|
||||||
|
32: { round: "Elimination Round 4", matchNumber: 1 },
|
||||||
|
31: { round: "Elimination Round 4", matchNumber: 2 },
|
||||||
|
// Elimination Final — U.S. G34; Intl G33
|
||||||
|
34: { round: "Elimination Final", matchNumber: 1 },
|
||||||
|
33: { round: "Elimination Final", matchNumber: 2 },
|
||||||
|
// Bracket Championship — U.S. G36; Intl G35
|
||||||
|
36: { round: "Bracket Championship", matchNumber: 1 },
|
||||||
|
35: { round: "Bracket Championship", matchNumber: 2 },
|
||||||
|
// Finals
|
||||||
|
37: { round: "Consolation Third Place", matchNumber: 1 },
|
||||||
|
38: { round: "World Championship", matchNumber: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MATCH_TO_GAME = new Map<string, number>(
|
||||||
|
Object.entries(GAME_TO_MATCH).map(([game, m]) => [
|
||||||
|
`${m.round}#${m.matchNumber}`,
|
||||||
|
Number(game),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
export function gameNumberFor(round: string, matchNumber: number): number {
|
||||||
|
const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
|
||||||
|
if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrows a destination that the test expects to exist. */
|
||||||
|
export function required<T>(destination: T | null): T {
|
||||||
|
if (destination === null) throw new Error("Expected a destination, got null");
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF game number a destination points at. */
|
||||||
|
export function destinationGame(
|
||||||
|
destination: { round: string; matchNumber: number } | null
|
||||||
|
): number {
|
||||||
|
const d = required(destination);
|
||||||
|
return gameNumberFor(d.round, d.matchNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The official bracket printed as feed labels: for each game, which prior game's
|
||||||
|
* winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
|
||||||
|
*
|
||||||
|
* Transcribed from the PDF. This is the source of truth the routing must reproduce.
|
||||||
|
*/
|
||||||
|
export const EXPECTED_SLOTS: Record<number, [string | null, string | null]> = {
|
||||||
|
// Opening Round — all directly seeded
|
||||||
|
1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
|
||||||
|
5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
|
||||||
|
// Winners Round 2 — bye team, then an Opening Round winner
|
||||||
|
9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
|
||||||
|
// Elimination Round 1
|
||||||
|
13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
|
||||||
|
// Winners Semifinals
|
||||||
|
17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
|
||||||
|
// Elimination Round 2
|
||||||
|
21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
|
||||||
|
// Elimination Round 3 — cross-over
|
||||||
|
25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
|
||||||
|
// Winners Final
|
||||||
|
29: ["W18", "W20"], 30: ["W17", "W19"],
|
||||||
|
// Elimination Round 4
|
||||||
|
31: ["W27", "W25"], 32: ["W28", "W26"],
|
||||||
|
// Elimination Final
|
||||||
|
33: ["L29", "W31"], 34: ["L30", "W32"],
|
||||||
|
// Bracket Championship
|
||||||
|
35: ["W29", "W33"], 36: ["W30", "W34"],
|
||||||
|
// Finals
|
||||||
|
37: ["L36", "L35"], 38: ["W36", "W35"],
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"app/models/**/*.ts",
|
"app/models/**/*.ts",
|
||||||
"app/services/**/*.ts",
|
"app/services/**/*.ts",
|
||||||
"app/lib/**/*.ts",
|
"app/lib/**/*.ts",
|
||||||
|
"app/test/fixtures/**/*.ts",
|
||||||
"app/types/**/*.ts",
|
"app/types/**/*.ts",
|
||||||
"vite.config.ts"
|
"vite.config.ts"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue