352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
|
|
/**
|
||
|
|
* 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" };
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (let ri = 1; ri < template.rounds.length; ri++) {
|
||
|
|
const round = template.rounds[ri];
|
||
|
|
const prev = template.rounds[ri - 1];
|
||
|
|
for (let n = 1; n <= round.matchCount; n++) {
|
||
|
|
const pair = slots(matchKey(round.name, n));
|
||
|
|
for (const [slotIdx, source] of [
|
||
|
|
[0, 2 * n - 1],
|
||
|
|
[1, 2 * n],
|
||
|
|
] as const) {
|
||
|
|
if (source > prev.matchCount) continue;
|
||
|
|
pair[slotIdx] = {
|
||
|
|
kind: "match",
|
||
|
|
ref: { round: prev.name, matchNumber: source },
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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: one column per round, matches spread evenly, no edges.
|
||
|
|
* Used when a group's shape can't be resolved into a single tree.
|
||
|
|
*/
|
||
|
|
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 columns = visibleRounds.map((round) => {
|
||
|
|
const matches = matchesByRound.get(round) ?? [];
|
||
|
|
const span = leafCount / Math.max(matches.length, 1);
|
||
|
|
return {
|
||
|
|
label: round,
|
||
|
|
matches: matches.map((match, i) => ({ match, center: (i + 0.5) * span })),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return { columns, leafCount, edges: [] };
|
||
|
|
}
|