@johnmorrisdotca/taisen-brackets 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/NOTICE.md +19 -0
- package/README.md +392 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/layout-entry.d.ts +6 -0
- package/dist/layout-entry.js +5 -0
- package/dist/layout.d.ts +8 -0
- package/dist/layout.js +261 -0
- package/dist/marks.d.ts +31 -0
- package/dist/marks.js +84 -0
- package/dist/matchup.d.ts +11 -0
- package/dist/matchup.js +147 -0
- package/dist/presets-entry.d.ts +3 -0
- package/dist/presets-entry.js +2 -0
- package/dist/presets.d.ts +43 -0
- package/dist/presets.js +81 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.js +478 -0
- package/dist/rounds.d.ts +8 -0
- package/dist/rounds.js +26 -0
- package/dist/seeding.d.ts +60 -0
- package/dist/seeding.js +141 -0
- package/dist/table.d.ts +14 -0
- package/dist/table.js +35 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.js +62 -0
- package/dist/theme.d.ts +21 -0
- package/dist/theme.js +53 -0
- package/dist/types.d.ts +281 -0
- package/dist/types.js +5 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/dist/words.d.ts +7 -0
- package/dist/words.js +33 -0
- package/package.json +100 -4
package/dist/presets.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playoff formats by era: the round names, the length of each round's series and who has a bye, for the five North
|
|
3
|
+
* American leagues and the FIFA World Cup. Data only; nothing here draws. Each era was checked against the English
|
|
4
|
+
* Wikipedia articles in its `sources` (2026-10-13); where a season did something of its own (a strike, a pandemic) it has
|
|
5
|
+
* its own entry. The year is the year the playoffs are played in: the NHL's 2024 is the 2023-24 season.
|
|
6
|
+
*/
|
|
7
|
+
/** The address of English Wikipedia articles, by title. */
|
|
8
|
+
const wiki = (...titles) => titles.map((t) => `https://en.wikipedia.org/wiki/${t.replace(/ /g, "_")}`);
|
|
9
|
+
const r = (name, bestOf = 7) => ({ name, bestOf });
|
|
10
|
+
const sevens = (...names) => names.map((n) => r(n, 7));
|
|
11
|
+
const WEST_EAST = ["Western", "Eastern"];
|
|
12
|
+
const AL_NL = ["American League", "National League"];
|
|
13
|
+
const AFC_NFC = ["AFC", "NFC"];
|
|
14
|
+
const ERAS = {
|
|
15
|
+
nhl: [
|
|
16
|
+
{ league: "nhl", from: 1975, to: 1979, label: "12 teams, a best-of-three preliminary round", teams: 12, byes: 2, groups: null, rounds: [r("Preliminary Round", 3), ...sevens("Quarterfinals", "Semifinals", "Stanley Cup Final")], note: "Division winners had a bye; the second and third placed teams played a preliminary round.", sources: wiki("1977 Stanley Cup playoffs", "Stanley Cup playoffs") },
|
|
17
|
+
{ league: "nhl", from: 1980, to: 1981, label: "16 teams, a best-of-five preliminary round", teams: 16, byes: 0, groups: null, rounds: [r("Preliminary Round", 5), ...sevens("Quarterfinals", "Semifinals", "Stanley Cup Final")], sources: wiki("1981 Stanley Cup playoffs") },
|
|
18
|
+
{ league: "nhl", from: 1982, to: 1986, label: "16 teams by division, a best-of-five first round", teams: 16, byes: 0, groups: ["Campbell Conference", "Wales Conference"], rounds: [r("Division Semifinals", 5), ...sevens("Division Finals", "Conference Finals", "Stanley Cup Final")], sources: wiki("1984 Stanley Cup playoffs", "Clarence Campbell Conference", "Prince of Wales Conference") },
|
|
19
|
+
{ league: "nhl", from: 1987, to: 1993, label: "16 teams by division", teams: 16, byes: 0, groups: ["Campbell Conference", "Wales Conference"], rounds: sevens("Division Semifinals", "Division Finals", "Conference Finals", "Stanley Cup Final"), sources: wiki("1990 Stanley Cup playoffs", "Clarence Campbell Conference", "Prince of Wales Conference") },
|
|
20
|
+
{ league: "nhl", from: 1994, to: 2013, label: "16 teams, eight a conference, conference rounds", teams: 16, byes: 0, groups: WEST_EAST, rounds: sevens("Conference Quarterfinals", "Conference Semifinals", "Conference Finals", "Stanley Cup Final"), except: [2005], note: "No playoffs in 2005 (the 2004-05 season was not played).", sources: wiki("1996 Stanley Cup playoffs", "2013 Stanley Cup playoffs") },
|
|
21
|
+
{ league: "nhl", from: 2014, to: null, label: "16 teams, eight a conference, division rounds first", teams: 16, byes: 0, groups: WEST_EAST, rounds: sevens("First Round", "Second Round", "Conference Finals", "Stanley Cup Final"), except: [2020, 2021], note: "The first two rounds are played within divisions.", sources: wiki("2014 Stanley Cup playoffs") },
|
|
22
|
+
{ league: "nhl", from: 2020, to: 2020, label: "24 teams: a qualifying round, then 16", teams: 24, byes: 0, groups: WEST_EAST, rounds: [r("Qualifying Round", 5), ...sevens("First Round", "Second Round", "Conference Finals", "Stanley Cup Final")], note: "The top four teams of each conference played a round robin for seeding instead of the qualifying round.", sources: wiki("2020 Stanley Cup playoffs") },
|
|
23
|
+
{ league: "nhl", from: 2021, to: 2021, label: "16 teams in four divisions", teams: 16, byes: 0, groups: null, rounds: sevens("First Round", "Second Round", "Stanley Cup Semifinals", "Stanley Cup Final"), note: "Four temporary divisions: the semifinals were between division winners, not conferences.", sources: wiki("2021 Stanley Cup playoffs") },
|
|
24
|
+
],
|
|
25
|
+
nba: [
|
|
26
|
+
{ league: "nba", from: 1977, to: 1983, label: "12 teams, a best-of-three first round", teams: 12, byes: 2, groups: WEST_EAST, rounds: [r("First Round", 3), ...sevens("Conference Semifinals", "Conference Finals", "NBA Finals")], note: "Division winners had a bye.", sources: wiki("NBA playoffs") },
|
|
27
|
+
{ league: "nba", from: 1984, to: 2002, label: "16 teams, a best-of-five first round", teams: 16, byes: 0, groups: WEST_EAST, rounds: [r("First Round", 5), ...sevens("Conference Semifinals", "Conference Finals", "NBA Finals")], sources: wiki("NBA playoffs") },
|
|
28
|
+
{ league: "nba", from: 2003, to: null, label: "16 teams, best-of-seven throughout", teams: 16, byes: 0, groups: WEST_EAST, rounds: sevens("First Round", "Conference Semifinals", "Conference Finals", "NBA Finals"), note: "Since 2021 a play-in tournament decides the seventh and eighth seeds of each conference.", sources: wiki("NBA playoffs") },
|
|
29
|
+
],
|
|
30
|
+
mlb: [
|
|
31
|
+
{ league: "mlb", from: 1969, to: 1984, label: "two rounds, a best-of-five league championship series", teams: 4, byes: 0, groups: AL_NL, rounds: [r("League Championship Series", 5), r("World Series", 7)], except: [1981], sources: wiki("Major League Baseball postseason") },
|
|
32
|
+
{ league: "mlb", from: 1981, to: 1981, label: "a split season: a division series first", teams: 8, byes: 0, groups: AL_NL, rounds: [r("Division Series", 5), r("League Championship Series", 5), r("World Series", 7)], sources: wiki("Major League Baseball postseason") },
|
|
33
|
+
{ league: "mlb", from: 1985, to: 1993, label: "two rounds, a best-of-seven league championship series", teams: 4, byes: 0, groups: AL_NL, rounds: [r("League Championship Series", 7), r("World Series", 7)], sources: wiki("Major League Baseball postseason") },
|
|
34
|
+
{ league: "mlb", from: 1995, to: 2011, label: "8 teams, division series", teams: 8, byes: 0, groups: AL_NL, rounds: [r("Division Series", 5), r("League Championship Series", 7), r("World Series", 7)], note: "No postseason in 1994: the season ended on 11 August with the players' strike.", sources: wiki("Major League Baseball postseason", "1994 Major League Baseball season") },
|
|
35
|
+
{ league: "mlb", from: 2012, to: 2019, label: "10 teams, a wild card game", teams: 10, byes: 3, groups: AL_NL, rounds: [r("Wild Card Game", 1), r("Division Series", 5), r("League Championship Series", 7), r("World Series", 7)], note: "The three division winners of each league skip the wild card game.", except: [2020], sources: wiki("Major League Baseball postseason") },
|
|
36
|
+
{ league: "mlb", from: 2020, to: 2020, label: "16 teams, a best-of-three wild card series", teams: 16, byes: 0, groups: AL_NL, rounds: [r("Wild Card Series", 3), r("Division Series", 5), r("League Championship Series", 7), r("World Series", 7)], sources: wiki("Major League Baseball postseason") },
|
|
37
|
+
{ league: "mlb", from: 2021, to: 2021, label: "10 teams, a wild card game", teams: 10, byes: 3, groups: AL_NL, rounds: [r("Wild Card Game", 1), r("Division Series", 5), r("League Championship Series", 7), r("World Series", 7)], sources: wiki("Major League Baseball postseason") },
|
|
38
|
+
{ league: "mlb", from: 2022, to: null, label: "12 teams, the top two seeds of each league have a bye", teams: 12, byes: 2, groups: AL_NL, rounds: [r("Wild Card Series", 3), r("Division Series", 5), r("League Championship Series", 7), r("World Series", 7)], sources: wiki("Major League Baseball postseason") },
|
|
39
|
+
],
|
|
40
|
+
nfl: [
|
|
41
|
+
{ league: "nfl", from: 1970, to: 1977, label: "8 teams", teams: 8, byes: 0, groups: AFC_NFC, rounds: [r("Divisional Playoffs", 1), r("Conference Championships", 1), r("Super Bowl", 1)], sources: wiki("NFL playoffs", "1975–76 NFL playoffs") },
|
|
42
|
+
{ league: "nfl", from: 1978, to: 1989, label: "10 teams, the division winners have a bye", teams: 10, byes: 3, groups: AFC_NFC, rounds: [r("Wild Card", 1), r("Divisional", 1), r("Conference Championship", 1), r("Super Bowl", 1)], except: [1982], sources: wiki("NFL playoffs", "1983–84 NFL playoffs") },
|
|
43
|
+
{ league: "nfl", from: 1982, to: 1982, label: "16 teams after the players' strike", teams: 16, byes: 0, groups: AFC_NFC, rounds: [r("First Round", 1), r("Second Round", 1), r("Conference Championship", 1), r("Super Bowl", 1)], sources: wiki("NFL playoffs", "1982–83 NFL playoffs") },
|
|
44
|
+
{ league: "nfl", from: 1990, to: 2019, label: "12 teams, the top two seeds have a bye", teams: 12, byes: 2, groups: AFC_NFC, rounds: [r("Wild Card", 1), r("Divisional", 1), r("Conference Championship", 1), r("Super Bowl", 1)], sources: wiki("NFL playoffs") },
|
|
45
|
+
{ league: "nfl", from: 2020, to: null, label: "14 teams, the top seed has a bye", teams: 14, byes: 1, groups: AFC_NFC, rounds: [r("Wild Card", 1), r("Divisional", 1), r("Conference Championship", 1), r("Super Bowl", 1)], sources: wiki("NFL playoffs") },
|
|
46
|
+
],
|
|
47
|
+
cfl: [
|
|
48
|
+
{ league: "cfl", from: 1996, to: 2026, label: "division semi-finals and finals, with a crossover", teams: 6, byes: 1, groups: ["West", "East"], rounds: [r("Division Semi-Final", 1), r("Division Final", 1), r("Grey Cup", 1)], note: "Three teams of each division qualify and the first-placed team has a bye. A fourth-placed team with a better record than the third-placed team of the other division takes that place (the crossover, since 1996). The league has announced an eight-team format from 2027.", sources: wiki("1996 CFL season", "2012 CFL season", "2019 CFL season", "CFL playoffs") },
|
|
49
|
+
],
|
|
50
|
+
worldcup: [
|
|
51
|
+
{ league: "worldcup", from: 1930, to: 1930, label: "four group winners, straight to the semi-finals", teams: 4, byes: 0, groups: null, rounds: [r("Semi-finals", 1), r("Final", 1)], years: [1930], sources: wiki("1930 FIFA World Cup") },
|
|
52
|
+
{ league: "worldcup", from: 1934, to: 1938, label: "16 teams, straight knockout, drawn matches replayed", teams: 16, byes: 0, groups: null, rounds: [r("Round of 16", 1), r("Quarter-finals", 1), r("Semi-finals", 1), r("Final", 1)], years: [1934, 1938], note: "A match still level after extra time was replayed (1934, 1938). There was a third-place match in both. In 1938 Austria withdrew, so 15 teams played.", sources: wiki("1934 FIFA World Cup", "FIFA World Cup") },
|
|
53
|
+
{ league: "worldcup", from: 1954, to: 1970, label: "knockout from the quarter-finals", teams: 8, byes: 0, groups: null, rounds: [r("Quarter-finals", 1), r("Semi-finals", 1), r("Final", 1)], years: [1954, 1958, 1962, 1966, 1970], note: "With a third-place match.", sources: wiki("1954 FIFA World Cup", "FIFA World Cup") },
|
|
54
|
+
{ league: "worldcup", from: 1974, to: 1978, label: "a second group stage, then the final", teams: 4, byes: 0, groups: null, rounds: [r("Final", 1)], years: [1974, 1978], note: "No semi-finals: the winners of the two second-round groups played the final, the runners-up the third-place match.", sources: wiki("1974 FIFA World Cup", "1978 FIFA World Cup") },
|
|
55
|
+
{ league: "worldcup", from: 1982, to: 1982, label: "a second group stage, then semi-finals", teams: 4, byes: 0, groups: null, rounds: [r("Semi-finals", 1), r("Final", 1)], years: [1982], sources: wiki("1982 FIFA World Cup") },
|
|
56
|
+
{ league: "worldcup", from: 1986, to: 2022, label: "16 teams in the knockout", teams: 16, byes: 0, groups: null, rounds: [r("Round of 16", 1), r("Quarter-finals", 1), r("Semi-finals", 1), r("Final", 1)], years: [1986, 1990, 1994, 1998, 2002, 2006, 2010, 2014, 2018, 2022], note: "With a third-place match.", sources: wiki("1986 FIFA World Cup", "FIFA World Cup") },
|
|
57
|
+
{ league: "worldcup", from: 2026, to: null, label: "48 teams, a round of 32", teams: 32, byes: 0, groups: null, rounds: [r("Round of 32", 1), r("Round of 16", 1), r("Quarter-finals", 1), r("Semi-finals", 1), r("Final", 1)], years: [2026], note: "With a third-place match. Later editions are not covered until checked.", sources: wiki("FIFA World Cup") },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
/** The leagues that have a format here. */
|
|
61
|
+
export const LEAGUES = Object.freeze(Object.keys(ERAS));
|
|
62
|
+
/**
|
|
63
|
+
* The playoff format of a league in a season (or a World Cup in its year), or `null` when there was none or this
|
|
64
|
+
* package does not cover it. The rounds' names go straight to `drawBracket`'s `roundNames`.
|
|
65
|
+
*/
|
|
66
|
+
export function playoffFormat(league, year) {
|
|
67
|
+
const list = ERAS[league];
|
|
68
|
+
if (!list)
|
|
69
|
+
throw new Error(`taisen-brackets: no format for ${JSON.stringify(league)}; leagues are ${LEAGUES.join(", ")}`);
|
|
70
|
+
const hit = list.find((e) => year >= e.from && (e.to === null || year <= e.to) && !(e.except ?? []).includes(year) && (!e.years || e.years.includes(year)));
|
|
71
|
+
if (!hit)
|
|
72
|
+
return null;
|
|
73
|
+
const { years: _years, except: _except, ...rest } = hit;
|
|
74
|
+
void _years;
|
|
75
|
+
void _except;
|
|
76
|
+
return { ...rest, year };
|
|
77
|
+
}
|
|
78
|
+
/** Every era of a league, oldest first. */
|
|
79
|
+
export function playoffEras(league) {
|
|
80
|
+
return ERAS[league].map(({ years: _y, except: _e, ...rest }) => { void _y; void _e; return { ...rest, year: rest.from }; });
|
|
81
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BracketInput, BracketLayout, DrawOptions, Matchup, Size, SizeName, Words } from "./types.ts";
|
|
2
|
+
/** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs. */
|
|
3
|
+
export declare const SIZES: Readonly<Record<SizeName, Size>>;
|
|
4
|
+
/** The text a screen reader gets for the drawing as a whole. */
|
|
5
|
+
export declare function describeBracket(input: BracketLayout | readonly Matchup[] | BracketInput, options?: {
|
|
6
|
+
words?: Partial<Words>;
|
|
7
|
+
roundNames?: DrawOptions["roundNames"];
|
|
8
|
+
}): string;
|
|
9
|
+
/**
|
|
10
|
+
* Draws a bracket as an SVG string. `input` is a list of matchups, `{ matchups, teams }` or a layout from `layoutBracket`.
|
|
11
|
+
* The same input and options always give the same string: no clock, no random number, no counter.
|
|
12
|
+
*/
|
|
13
|
+
export declare function drawBracket(input: BracketLayout | readonly Matchup[] | BracketInput, options?: DrawOptions): string;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { layoutBracket } from "./layout.js";
|
|
2
|
+
import { abbrOf, describeTie, noteParts } from "./matchup.js";
|
|
3
|
+
import { cleanSvg, clipId, drawMark, safeUrl } from "./marks.js";
|
|
4
|
+
import { roundLabel } from "./rounds.js";
|
|
5
|
+
import { styleFor } from "./theme.js";
|
|
6
|
+
import { esc, fit, hash, num, textWidth } from "./text.js";
|
|
7
|
+
import { fill, wordsWith } from "./words.js";
|
|
8
|
+
/** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs. */
|
|
9
|
+
export const SIZES = Object.freeze({
|
|
10
|
+
wide: { width: 1200, height: 675 },
|
|
11
|
+
square: { width: 1080, height: 1080 },
|
|
12
|
+
phone: { width: 390, height: null },
|
|
13
|
+
});
|
|
14
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
15
|
+
const isLayout = (x) => typeof x === "object" && x !== null && "ties" in x && "sides" in x && "rounds" in x;
|
|
16
|
+
/** The text a screen reader gets for the drawing as a whole. */
|
|
17
|
+
export function describeBracket(input, options = {}) {
|
|
18
|
+
const layout = isLayout(input) ? input : layoutBracket(input);
|
|
19
|
+
const words = wordsWith(options.words);
|
|
20
|
+
const parts = [fill(layout.rounds === 1 ? words.summaryOne : words.summary, { n: layout.teams.length, r: layout.rounds })];
|
|
21
|
+
const finalTie = layout.finalIndex !== null ? layout.ties[layout.finalIndex] : null;
|
|
22
|
+
if (finalTie)
|
|
23
|
+
parts.push(`${roundLabel(layout, layout.rounds, options.roundNames, words)}: ${describeTie(finalTie, words)}`);
|
|
24
|
+
if (layout.champion)
|
|
25
|
+
parts.push(`${words.champion}: ${layout.champion.name}`);
|
|
26
|
+
return `${parts.join(". ")}.`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Draws a bracket as an SVG string. `input` is a list of matchups, `{ matchups, teams }` or a layout from `layoutBracket`.
|
|
30
|
+
* The same input and options always give the same string: no clock, no random number, no counter.
|
|
31
|
+
*/
|
|
32
|
+
export function drawBracket(input, options = {}) {
|
|
33
|
+
const layout = isLayout(input) ? input : layoutBracket(input, options);
|
|
34
|
+
const words = wordsWith(options.words);
|
|
35
|
+
const theme = options.theme ?? "auto";
|
|
36
|
+
const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
|
|
37
|
+
const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
|
|
38
|
+
const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
|
|
39
|
+
const prefix = options.id ?? `tb-${hash(JSON.stringify([layout.ties.map((t) => [t.id, t.sides.map((s) => s?.id ?? null), t.winner, t.result.score, t.round]), options.size, orientation, options.detail, theme, options.title, options.subtitle, options.seeds, options.scores, options.champion, options.markShape, options.roundLabels, options.thirdPlace, options.trophy ? 1 : 0]))}`;
|
|
40
|
+
const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round" };
|
|
41
|
+
const plan = planBracket(layout, options, words, size, orientation === "vertical", ctx);
|
|
42
|
+
const drawn = plan.boxes.map((b) => drawBox(b, plan, options, words, ctx)); // draws the marks, so before the defs are written
|
|
43
|
+
const title = options.title ?? words.title;
|
|
44
|
+
const desc = describeBracket(layout, options);
|
|
45
|
+
const defs = [
|
|
46
|
+
...[...ctx.radii].sort((a, b) => a - b).map((r) => `<clipPath id="${clipId(prefix, r)}"><circle r="${num(r)}"/></clipPath>`),
|
|
47
|
+
...[...ctx.symbols.values()].map((s) => `<symbol id="${s.id}" viewBox="${esc(s.viewBox)}" preserveAspectRatio="xMidYMid slice">${s.inner}</symbol>`),
|
|
48
|
+
].join("");
|
|
49
|
+
const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme)}</style>`;
|
|
50
|
+
const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>`;
|
|
51
|
+
return [
|
|
52
|
+
`<svg xmlns="http://www.w3.org/2000/svg" class="taisen ${prefix}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(plan.width)} ${num(plan.height)}" width="${num(plan.width)}" height="${num(plan.height)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${prefix}-t ${prefix}-d">`,
|
|
53
|
+
`<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
|
|
54
|
+
style,
|
|
55
|
+
defs ? `<defs>${defs}</defs>` : "",
|
|
56
|
+
bg,
|
|
57
|
+
...plan.links,
|
|
58
|
+
...drawn,
|
|
59
|
+
...plan.texts,
|
|
60
|
+
...plan.extras,
|
|
61
|
+
"</svg>",
|
|
62
|
+
].filter(Boolean).join("\n");
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------------------------- the plan
|
|
65
|
+
function planBracket(layout, o, words, size, vertical, ctx) {
|
|
66
|
+
const pad = vertical ? 14 : 22;
|
|
67
|
+
const showLabels = o.roundLabels ?? true;
|
|
68
|
+
const poster = o.theme === "poster";
|
|
69
|
+
const boxes = [];
|
|
70
|
+
const links = [];
|
|
71
|
+
const texts = [];
|
|
72
|
+
const extras = [];
|
|
73
|
+
const W0 = size.width;
|
|
74
|
+
const titleH = (o.title ? (poster ? 46 : 38) : 0) + (o.subtitle ? 24 : 0) + (o.title || o.subtitle ? 10 : 0);
|
|
75
|
+
const hasGroups = layout.sides.some((s) => s.label);
|
|
76
|
+
const champ = (o.champion ?? true) && layout.champion !== null && layout.finalIndex !== null;
|
|
77
|
+
const withThird = layout.third !== null && (o.thirdPlace ?? "below") !== "hide";
|
|
78
|
+
// Beside the final in a horizontal bracket of two halves, under the final; elsewhere the lines into the final would run through it, so it goes at the foot.
|
|
79
|
+
const bottomThird = withThird && !(layout.shape === "halves" && !vertical);
|
|
80
|
+
const thirdBlock = bottomThird ? 18 + (vertical ? 52 : OTHER_H) + 16 : 0;
|
|
81
|
+
const halves = layout.shape === "halves";
|
|
82
|
+
const nc = layout.columns.length;
|
|
83
|
+
const rows = layout.rows;
|
|
84
|
+
const hasTrophy = !!o.trophy && champ;
|
|
85
|
+
// ----- slots along the rounds' axis (t): the columns of the left half, the centre, the columns of the right half.
|
|
86
|
+
// Horizontal: t is x. Vertical: t is y, and the left half is the top.
|
|
87
|
+
const slotCount = halves ? 2 * nc + 1 : nc + (champ || layout.finalIndex !== null ? 1 : 0);
|
|
88
|
+
const slotOfColumn = (side, c) => (side === "l" ? c : slotCount - 1 - c);
|
|
89
|
+
// Dimensions of a box: w across the page, h down the page.
|
|
90
|
+
let D = 0, S = 0; // the box along the rounds' axis, and across it
|
|
91
|
+
let gapT = 0;
|
|
92
|
+
let rowStep = 0;
|
|
93
|
+
let centreT = 0; // the centre slot's size along t
|
|
94
|
+
let boxW = 0, boxH = 0;
|
|
95
|
+
let others = [];
|
|
96
|
+
let ow = 0;
|
|
97
|
+
let extraH0 = 0;
|
|
98
|
+
if (!vertical) {
|
|
99
|
+
const availW = W0 - 2 * pad;
|
|
100
|
+
const g = 0.2;
|
|
101
|
+
const centreMin = halves ? 168 : 150;
|
|
102
|
+
let d = (availW - centreMin) / ((halves ? 2 : 1) * Math.max(nc, 1) * (1 + g));
|
|
103
|
+
if (1.2 * d > centreMin)
|
|
104
|
+
d = availW / ((halves ? 2 : 1) * Math.max(nc, 1) * (1 + g) + 1.2);
|
|
105
|
+
D = clamp(d, 40, 220);
|
|
106
|
+
gapT = D * g;
|
|
107
|
+
centreT = Math.max(centreMin, D * 1.2);
|
|
108
|
+
const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
|
|
109
|
+
others = othersOf(layout);
|
|
110
|
+
ow = clamp(D * 1.1, 120, 200);
|
|
111
|
+
extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
|
|
112
|
+
const bodyH = size.height === null ? rows * 60 : size.height - top - pad - extraH0;
|
|
113
|
+
rowStep = bodyH / rows;
|
|
114
|
+
S = clamp(rowStep - 9, 24, 104);
|
|
115
|
+
rowStep = Math.max(rowStep, S + 6);
|
|
116
|
+
rowStep = Math.min(rowStep, S + 40);
|
|
117
|
+
boxW = D;
|
|
118
|
+
boxH = S;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const availW = W0 - 2 * pad;
|
|
122
|
+
const slot = availW / Math.max(rows, 1);
|
|
123
|
+
S = clamp(slot - 5, 30, 150);
|
|
124
|
+
rowStep = Math.min(slot, S + 5);
|
|
125
|
+
boxW = S;
|
|
126
|
+
}
|
|
127
|
+
// Full boxes (names) only where the longest name fits beside its mark and a score.
|
|
128
|
+
const longest = Math.max(0, ...layout.teams.map((t) => textWidth(t.name, 13)));
|
|
129
|
+
const pick = (w, h) => {
|
|
130
|
+
const rowH = h / 2;
|
|
131
|
+
const font = clamp(rowH * 0.5, 8.5, 16);
|
|
132
|
+
const md = clamp(rowH * 0.7, 10, 28);
|
|
133
|
+
const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", font + 1) + 14;
|
|
134
|
+
const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
|
|
135
|
+
return named === "mark" && o.markShape === "none" ? "compact" : named;
|
|
136
|
+
};
|
|
137
|
+
const detail = pick(boxW, vertical ? 48 : boxH);
|
|
138
|
+
if (vertical)
|
|
139
|
+
boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
|
|
140
|
+
if (vertical) {
|
|
141
|
+
D = boxH;
|
|
142
|
+
gapT = showLabels ? 26 : 16;
|
|
143
|
+
}
|
|
144
|
+
// The champion's block: a trophy, a large mark, the name and the word; and the third-place match below the final.
|
|
145
|
+
const champMark = vertical ? 54 : clamp(centreT * 0.34, 44, 84);
|
|
146
|
+
const trophyH = hasTrophy ? (vertical ? 56 : clamp(centreT * 0.45, 48, 84)) : 0;
|
|
147
|
+
const nameFont = clamp(champMark * 0.3, 13, 20);
|
|
148
|
+
const champH = champ ? trophyH + (hasTrophy ? 6 : 0) + champMark + 8 + nameFont + 6 + 11 + 2 : 0;
|
|
149
|
+
const finalW = halves ? (vertical ? Math.min(size.width - 2 * pad, Math.max(boxW, 160)) : Math.min(centreT - 12, Math.max(D * 1.1, 120))) : D;
|
|
150
|
+
const finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
|
|
151
|
+
const thirdH = withThird && !bottomThird ? 18 + boxH + 14 : 0;
|
|
152
|
+
// ----- the slot positions along t
|
|
153
|
+
if (!vertical) {
|
|
154
|
+
const sizes = [];
|
|
155
|
+
for (let k = 0; k < slotCount; k++)
|
|
156
|
+
sizes.push(halves && k === nc ? centreT : !halves && k === nc ? Math.max(centreMinLtr(D), 150) : D);
|
|
157
|
+
const total = sizes.reduce((a, b) => a + b, 0) + gapT * (slotCount - 1);
|
|
158
|
+
let t = pad + Math.max(0, (W0 - 2 * pad - total) / 2);
|
|
159
|
+
const starts = [];
|
|
160
|
+
sizes.forEach((sz) => { starts.push(t); t += sz + gapT; });
|
|
161
|
+
const width = Math.max(W0, total + 2 * pad);
|
|
162
|
+
// ----- span positions
|
|
163
|
+
const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
|
|
164
|
+
const treeH = rows * rowStep;
|
|
165
|
+
// The final sits at the middle of the rows; the champion above it and the third place below need room.
|
|
166
|
+
const upNeed = (champ ? champH + 8 : 0) + 22 + finalH / 2;
|
|
167
|
+
const downNeed = finalH / 2 + (withThird ? thirdH : 0);
|
|
168
|
+
const half = Math.max(treeH / 2, upNeed, downNeed);
|
|
169
|
+
const bodyH = half * 2;
|
|
170
|
+
const height = Math.max(size.height ?? 0, top + bodyH + pad + extraH0);
|
|
171
|
+
const body0 = top + (height - top - pad - extraH0 - bodyH) / 2;
|
|
172
|
+
const treeY0 = body0 + (bodyH - treeH) / 2;
|
|
173
|
+
const yOf = (row) => treeY0 + row * rowStep + (rowStep - S) / 2;
|
|
174
|
+
const place = (side, b) => {
|
|
175
|
+
const c = layout.columns.indexOf(b.round);
|
|
176
|
+
const k = halves ? slotOfColumn(side, c) : c;
|
|
177
|
+
return { x: starts[k], y: yOf(b.y), w: D, h: S };
|
|
178
|
+
};
|
|
179
|
+
const rects = new Map();
|
|
180
|
+
for (const sd of layout.sides)
|
|
181
|
+
for (const b of sd.boxes) {
|
|
182
|
+
const rect = place(sd.key, b);
|
|
183
|
+
rects.set(b.i, rect);
|
|
184
|
+
boxes.push({ rect, tie: layout.ties[b.i], order: b.order, bye: b.bye, final: false, champion: false });
|
|
185
|
+
}
|
|
186
|
+
const midX = (halves ? starts[nc] : starts[nc] ?? 0) + (halves ? centreT : 0) / 2;
|
|
187
|
+
if (halves && layout.final) {
|
|
188
|
+
const fy = treeY0 + layout.final.y * rowStep + rowStep / 2;
|
|
189
|
+
const rect = { x: midX - finalW / 2, y: fy - finalH / 2, w: finalW, h: finalH };
|
|
190
|
+
rects.set(layout.final.i, rect);
|
|
191
|
+
boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, final: true, champion: champ });
|
|
192
|
+
if (showLabels)
|
|
193
|
+
texts.push(label(roundLabel(layout, layout.rounds, o.roundNames, words), midX, rect.y - 7, poster, "middle"));
|
|
194
|
+
if (champ)
|
|
195
|
+
extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, words, ctx, poster));
|
|
196
|
+
if (withThird) {
|
|
197
|
+
const t3 = layout.ties[layout.third];
|
|
198
|
+
const r3 = { x: midX - finalW / 2, y: rect.y + rect.h + 14 + 18, w: finalW, h: boxH };
|
|
199
|
+
rects.set(layout.third, r3);
|
|
200
|
+
texts.push(label(words.thirdPlace, midX, r3.y - 6, poster, "middle"));
|
|
201
|
+
boxes.push({ rect: r3, tie: t3, order: [0, 1], bye: null, final: false, champion: false });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (!halves) {
|
|
205
|
+
const k = nc;
|
|
206
|
+
const fi = layout.finalIndex;
|
|
207
|
+
const fb = fi !== null ? layout.sides[0]?.boxes.find((b) => b.i === fi) : undefined;
|
|
208
|
+
if (fb && champ) {
|
|
209
|
+
const cx = starts[k] + sizes[k] / 2;
|
|
210
|
+
const fy = rects.get(fi).y + S / 2;
|
|
211
|
+
extras.push(...championBlock(layout.champion, cx, fy - champH / 2, champMark, trophyH, nameFont, o, words, ctx, poster));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (showLabels) {
|
|
215
|
+
for (const c of layout.columns) {
|
|
216
|
+
const name = roundLabel(layout, c, o.roundNames, words);
|
|
217
|
+
const ci = layout.columns.indexOf(c);
|
|
218
|
+
const ks = halves ? [slotOfColumn("l", ci), slotOfColumn("r", ci)] : [ci];
|
|
219
|
+
const used = layout.sides.length > 1 || !halves ? (halves ? [0, 1] : [0]) : [0];
|
|
220
|
+
ks.forEach((k, n) => { if (used.includes(n) && (!halves || n < layout.sides.length))
|
|
221
|
+
texts.push(label(name, starts[k] + D / 2, top - 9, poster, "middle", D + gapT * 0.8)); });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (hasGroups) {
|
|
225
|
+
layout.sides.forEach((sd) => {
|
|
226
|
+
if (!sd.label)
|
|
227
|
+
return;
|
|
228
|
+
const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
|
|
229
|
+
const x0 = Math.min(...ks.map((k) => starts[k])), x1 = Math.max(...ks.map((k) => starts[k] + D));
|
|
230
|
+
texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(top - (showLabels ? 36 : 14))}" font-size="${poster ? 16 : 15}" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(poster ? sd.label.toUpperCase() : sd.label)}</text>`);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// lines
|
|
234
|
+
for (const t of layout.ties) {
|
|
235
|
+
const p = layout.parent[t.index];
|
|
236
|
+
if (p === -1)
|
|
237
|
+
continue;
|
|
238
|
+
const a = rects.get(t.index), b = rects.get(p);
|
|
239
|
+
if (!a || !b || layout.place[t.index] === "o")
|
|
240
|
+
continue;
|
|
241
|
+
const goingRight = b.x > a.x;
|
|
242
|
+
const x0 = goingRight ? a.x + a.w : a.x, x1 = goingRight ? b.x : b.x + b.w;
|
|
243
|
+
const y0 = a.y + a.h / 2, y1 = b.y + b.h / 2;
|
|
244
|
+
const xm = (x0 + x1) / 2;
|
|
245
|
+
links.push(`<path class="tb-line" d="M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1)}"${lineAttrs(o)}/>`);
|
|
246
|
+
}
|
|
247
|
+
if (bottomThird)
|
|
248
|
+
thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, words, poster, boxes, texts);
|
|
249
|
+
othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, words, poster, boxes, texts);
|
|
250
|
+
texts.push(...headings(o, width, pad, poster));
|
|
251
|
+
return { width, height, boxes, links, texts, extras, detail, vertical };
|
|
252
|
+
}
|
|
253
|
+
// ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
|
|
254
|
+
const bandT = D + gapT;
|
|
255
|
+
const groupBand = hasGroups ? 26 : 0;
|
|
256
|
+
// On a tall page the champion sits at the top, as the page's first thing; the lines into the final must not run through it.
|
|
257
|
+
const champTop = halves && champ ? champH + 14 : 0;
|
|
258
|
+
const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
|
|
259
|
+
const topT = pad + titleH + champTop + groupBand + (showLabels ? 14 : 0);
|
|
260
|
+
const bandsPerSide = nc;
|
|
261
|
+
const totalT = halves ? topT + bandsPerSide * bandT + centreBand + bandsPerSide * bandT + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * bandT + (champ ? champH : 0) + pad;
|
|
262
|
+
others = othersOf(layout);
|
|
263
|
+
ow = Math.min(W0 - 2 * pad, 150);
|
|
264
|
+
const extraV = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
|
|
265
|
+
const height = Math.max(size.height ?? 0, totalT + extraV);
|
|
266
|
+
const treeW = rows * rowStep;
|
|
267
|
+
const x0 = pad + (W0 - 2 * pad - treeW) / 2;
|
|
268
|
+
const xOf = (row) => x0 + row * rowStep + (rowStep - S) / 2;
|
|
269
|
+
const bandStart = (side, c) => {
|
|
270
|
+
if (!halves)
|
|
271
|
+
return topT + c * bandT;
|
|
272
|
+
// Top half: round 1 at the top; bottom half: round 1 at the bottom.
|
|
273
|
+
if (side === "l")
|
|
274
|
+
return topT + c * bandT;
|
|
275
|
+
return topT + bandsPerSide * bandT + centreBand + (bandsPerSide - 1 - c) * bandT;
|
|
276
|
+
};
|
|
277
|
+
const rects = new Map();
|
|
278
|
+
for (const sd of layout.sides)
|
|
279
|
+
for (const b of sd.boxes) {
|
|
280
|
+
const c = layout.columns.indexOf(b.round);
|
|
281
|
+
const rect = { x: xOf(b.y), y: bandStart(sd.key, c), w: S, h: D };
|
|
282
|
+
rects.set(b.i, rect);
|
|
283
|
+
boxes.push({ rect, tie: layout.ties[b.i], order: b.order, bye: b.bye, final: false, champion: false });
|
|
284
|
+
}
|
|
285
|
+
const midX = W0 / 2;
|
|
286
|
+
if (halves && layout.final) {
|
|
287
|
+
const cy0 = topT + bandsPerSide * bandT;
|
|
288
|
+
let y = cy0;
|
|
289
|
+
if (champ)
|
|
290
|
+
extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, words, ctx, poster));
|
|
291
|
+
if (showLabels) {
|
|
292
|
+
texts.push(label(roundLabel(layout, layout.rounds, o.roundNames, words), midX, y + 12, poster, "middle"));
|
|
293
|
+
y += 16;
|
|
294
|
+
}
|
|
295
|
+
const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
|
|
296
|
+
rects.set(layout.final.i, rect);
|
|
297
|
+
boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, final: true, champion: champ });
|
|
298
|
+
}
|
|
299
|
+
else if (!halves && champ && layout.finalIndex !== null) {
|
|
300
|
+
const fr = rects.get(layout.finalIndex);
|
|
301
|
+
extras.push(...championBlock(layout.champion, fr.x + fr.w / 2, fr.y + fr.h + 12, champMark, trophyH, nameFont, o, words, ctx, poster));
|
|
302
|
+
}
|
|
303
|
+
if (showLabels) {
|
|
304
|
+
layout.columns.forEach((c, ci) => {
|
|
305
|
+
const name = roundLabel(layout, c, o.roundNames, words);
|
|
306
|
+
const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
|
|
307
|
+
for (const sd of sides)
|
|
308
|
+
texts.push(`<text class="tb-round tb-halo" x="${num(pad)}" y="${num(bandStart(sd, ci) - 8)}" font-size="11" font-weight="600" letter-spacing=".08em">${esc(poster ? name.toUpperCase() : name.toUpperCase())}</text>`);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (hasGroups) {
|
|
312
|
+
layout.sides.forEach((sd) => {
|
|
313
|
+
if (!sd.label)
|
|
314
|
+
return;
|
|
315
|
+
const y = sd.key === "l" ? pad + titleH + champTop + 16 : totalT - pad - 2;
|
|
316
|
+
texts.push(`<text class="tb-half" x="${num(midX)}" y="${num(y)}" font-size="14" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(sd.label.toUpperCase())}</text>`);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
for (const t of layout.ties) {
|
|
320
|
+
const p = layout.parent[t.index];
|
|
321
|
+
if (p === -1)
|
|
322
|
+
continue;
|
|
323
|
+
const a = rects.get(t.index), b = rects.get(p);
|
|
324
|
+
if (!a || !b || layout.place[t.index] === "o")
|
|
325
|
+
continue;
|
|
326
|
+
const down = b.y > a.y;
|
|
327
|
+
const y0 = down ? a.y + a.h : a.y, y1 = down ? b.y : b.y + b.h;
|
|
328
|
+
const x0c = a.x + a.w / 2, x1c = b.x + b.w / 2;
|
|
329
|
+
const ym = down ? y0 + gapT * 0.4 : y0 - gapT * 0.4;
|
|
330
|
+
links.push(`<path class="tb-line" d="M${num(x0c)} ${num(y0)}V${num(ym)}H${num(x1c)}V${num(y1)}"${lineAttrs(o)}/>`);
|
|
331
|
+
}
|
|
332
|
+
if (bottomThird)
|
|
333
|
+
thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, 200), 52, words, poster, boxes, texts);
|
|
334
|
+
othersBlock(layout, others, totalT - pad + thirdBlock + 6, W0, pad, ow, OTHER_H, words, poster, boxes, texts);
|
|
335
|
+
texts.push(...headings(o, W0, pad, poster));
|
|
336
|
+
return { width: W0, height, boxes, links, texts, extras, detail, vertical };
|
|
337
|
+
}
|
|
338
|
+
const OTHER_H = 50;
|
|
339
|
+
const centreMinLtr = (d) => d * 1.1;
|
|
340
|
+
function lineAttrs(o) {
|
|
341
|
+
return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
|
|
342
|
+
}
|
|
343
|
+
function label(text, x, y, poster, anchor, maxWidth = 1e9) {
|
|
344
|
+
void poster;
|
|
345
|
+
const up = text.toUpperCase();
|
|
346
|
+
return `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="11" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(fit(up, 11 * 1.18, maxWidth))}</text>`;
|
|
347
|
+
}
|
|
348
|
+
function headings(o, width, pad, poster) {
|
|
349
|
+
const out = [];
|
|
350
|
+
let y = pad + 24;
|
|
351
|
+
if (o.title) {
|
|
352
|
+
const text = poster ? o.title.toUpperCase() : o.title;
|
|
353
|
+
out.push(`<text class="tb-title" x="${num(width / 2)}" y="${num(y)}" font-size="${poster ? 26 : 24}" font-weight="800" ${poster ? 'letter-spacing=".14em" ' : ""}text-anchor="middle">${esc(text)}</text>`);
|
|
354
|
+
y += poster ? 24 : 22;
|
|
355
|
+
}
|
|
356
|
+
if (o.subtitle)
|
|
357
|
+
out.push(`<text class="tb-sub" x="${num(width / 2)}" y="${num(y)}" font-size="14" text-anchor="middle">${esc(o.subtitle)}</text>`);
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
/** The champion, centred on `cx`, its top at `top`: a trophy if there is one, a large mark, the name and the word. */
|
|
361
|
+
function championBlock(team, cx, top, mark, trophyH, nameFont, o, words, ctx, poster) {
|
|
362
|
+
const out = [];
|
|
363
|
+
let y = top;
|
|
364
|
+
if (o.trophy && trophyH) {
|
|
365
|
+
const t = o.trophy.trim();
|
|
366
|
+
const svg = t.startsWith("<") ? cleanSvg(t) : null;
|
|
367
|
+
if (svg)
|
|
368
|
+
out.push(`<svg class="tb-gold" x="${num(cx - trophyH / 2)}" y="${num(y)}" width="${num(trophyH)}" height="${num(trophyH)}" viewBox="${esc(svg.viewBox)}" preserveAspectRatio="xMidYMid meet">${svg.inner}</svg>`);
|
|
369
|
+
else if (safeUrl(t))
|
|
370
|
+
out.push(`<image href="${esc(t)}" x="${num(cx - trophyH / 2)}" y="${num(y)}" width="${num(trophyH)}" height="${num(trophyH)}" preserveAspectRatio="xMidYMid meet"/>`);
|
|
371
|
+
y += trophyH + 6;
|
|
372
|
+
}
|
|
373
|
+
const cy = y + mark / 2;
|
|
374
|
+
const name = fit(team.name, nameFont, 200);
|
|
375
|
+
if (ctx.shape !== "none")
|
|
376
|
+
out.push(`<g class="tb-champ">${drawMark(team, cx, cy, mark, ctx)}</g>`);
|
|
377
|
+
const nameY = cy + mark / 2 + 8 + nameFont * 0.8;
|
|
378
|
+
out.push(`<text class="tb-name" x="${num(cx)}" y="${num(nameY)}" font-size="${num(nameFont)}" font-weight="800" text-anchor="middle">${esc(poster ? name.toUpperCase() : name)}</text>`);
|
|
379
|
+
out.push(`<text class="tb-champ-label" x="${num(cx)}" y="${num(nameY + 6 + 11)}" font-size="11" font-weight="700" letter-spacing=".16em" text-anchor="middle">${esc(words.champion.toUpperCase())}</text>`);
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
/** The ties that are not part of the tree (they lead nowhere near the final, or are round-robin games), drawn in a grid below it. */
|
|
383
|
+
function othersOf(layout) {
|
|
384
|
+
return [...layout.other, ...layout.robin];
|
|
385
|
+
}
|
|
386
|
+
function othersHeight(count, width, pad, ow, oh) {
|
|
387
|
+
if (!count)
|
|
388
|
+
return 0;
|
|
389
|
+
const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
|
|
390
|
+
return 26 + Math.ceil(count / cols) * (oh + 10) + 6;
|
|
391
|
+
}
|
|
392
|
+
function othersBlock(layout, list, y0, width, pad, ow, oh, words, poster, boxes, texts) {
|
|
393
|
+
if (!list.length)
|
|
394
|
+
return;
|
|
395
|
+
const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
|
|
396
|
+
const x0 = pad + (width - 2 * pad - (Math.min(cols, list.length) * (ow + 10) - 10)) / 2;
|
|
397
|
+
texts.push(label(words.other, width / 2, y0 + 12, poster, "middle"));
|
|
398
|
+
list.forEach((i, n) => {
|
|
399
|
+
const rect = { x: x0 + (n % cols) * (ow + 10), y: y0 + 24 + Math.floor(n / cols) * (oh + 10), w: ow, h: oh };
|
|
400
|
+
boxes.push({ rect, tie: layout.ties[i], order: [0, 1], bye: null, final: false, champion: false });
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/** The third-place match with its label, centred on `cx`, its label at `y0`. */
|
|
404
|
+
function thirdAtFoot(layout, y0, cx, w, h, words, poster, boxes, texts) {
|
|
405
|
+
texts.push(label(words.thirdPlace, cx, y0 + 12, poster, "middle"));
|
|
406
|
+
boxes.push({ rect: { x: cx - w / 2, y: y0 + 18, w, h }, tie: layout.ties[layout.third], order: [0, 1], bye: null, final: false, champion: false });
|
|
407
|
+
}
|
|
408
|
+
// ---------------------------------------------------------------------------------------------- one box
|
|
409
|
+
function drawBox(p, plan, o, words, ctx) {
|
|
410
|
+
const { rect, tie } = p;
|
|
411
|
+
const { x, y, w, h } = rect;
|
|
412
|
+
const detail = plan.detail;
|
|
413
|
+
const scoresOn = o.scores ?? true;
|
|
414
|
+
const seedsOn = o.seeds ?? false;
|
|
415
|
+
const highlight = o.winnerHighlight ?? true;
|
|
416
|
+
const parts = [];
|
|
417
|
+
parts.push(`<rect class="tb-box" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(Math.min(7, h / 4))}"${o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".4"' : ""}/>`);
|
|
418
|
+
const r = tie.result;
|
|
419
|
+
const notes = noteParts(tie, words);
|
|
420
|
+
const noteH = detail !== "mark" && h >= 50 && notes.length ? 13 : 0;
|
|
421
|
+
const rowH = (h - noteH) / 2;
|
|
422
|
+
const rowsOrder = p.order;
|
|
423
|
+
const cls = (k) => (highlight && tie.winner !== null ? (tie.winner === k ? "tb-won" : "tb-lost") : "tb-pending");
|
|
424
|
+
if (detail === "mark") {
|
|
425
|
+
const stack = Math.min((h - 8) / 2, w - 8, 44);
|
|
426
|
+
const side = Math.min(h - 8, (w - 12) / 2, 44);
|
|
427
|
+
const stacked = stack >= side;
|
|
428
|
+
const md2 = Math.max(stacked ? stack : side, 8);
|
|
429
|
+
rowsOrder.forEach((k, n) => {
|
|
430
|
+
const team = tie.sides[k];
|
|
431
|
+
const cx = stacked ? x + w / 2 : x + w / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 3);
|
|
432
|
+
const cy = stacked ? y + h / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 1) : y + h / 2;
|
|
433
|
+
parts.push(`<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g>`);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
const font = clamp(rowH * 0.5, 8.5, 16);
|
|
438
|
+
const md = clamp(rowH * 0.7, 10, 28);
|
|
439
|
+
rowsOrder.forEach((k, n) => {
|
|
440
|
+
const team = tie.sides[k];
|
|
441
|
+
const ry = y + n * rowH;
|
|
442
|
+
const cy = ry + rowH / 2;
|
|
443
|
+
const rowParts = [];
|
|
444
|
+
let tx = x + 6;
|
|
445
|
+
if (o.markShape !== "none") {
|
|
446
|
+
rowParts.push(drawMark(team, x + 6 + md / 2, cy, md, ctx));
|
|
447
|
+
tx = x + 6 + md + 5;
|
|
448
|
+
}
|
|
449
|
+
const score = scoresOn && r.score ? String(r.score[k]) : "";
|
|
450
|
+
const pensText = scoresOn && r.pens ? `(${r.pens[k]})` : "";
|
|
451
|
+
const bye = p.bye === k;
|
|
452
|
+
const right = x + w - 6;
|
|
453
|
+
const scoreW = score ? textWidth(score, font + 1) : 0;
|
|
454
|
+
const pensW = pensText ? textWidth(pensText, font - 2) + 3 : 0;
|
|
455
|
+
const byeText = bye ? words.bye : "";
|
|
456
|
+
const byeW = byeText ? textWidth(byeText, Math.max(font - 2, 8)) : 0;
|
|
457
|
+
const avail = right - tx - (scoreW + pensW) - (byeW ? byeW + 6 : 0) - 6;
|
|
458
|
+
const seed = seedsOn && team?.seed !== undefined ? `${team.seed} ` : "";
|
|
459
|
+
const nm = team ? (detail === "full" ? team.name : abbrOf(team, words)) : words.tbd;
|
|
460
|
+
const text = fit(`${seed}${nm}`, font, Math.max(avail, 8));
|
|
461
|
+
rowParts.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(tx)}" y="${num(cy + font * 0.35)}" font-size="${num(font)}">${esc(text)}</text>`);
|
|
462
|
+
if (score)
|
|
463
|
+
rowParts.push(`<text class="tb-score" x="${num(right)}" y="${num(cy + (font + 1) * 0.35)}" font-size="${num(font + 1)}" text-anchor="end" font-variant-numeric="tabular-nums">${esc(score)}</text>`);
|
|
464
|
+
if (pensText)
|
|
465
|
+
rowParts.push(`<text class="tb-muted" x="${num(right - scoreW - 3)}" y="${num(cy + (font - 2) * 0.35)}" font-size="${num(Math.max(font - 2, 8))}" text-anchor="end">${esc(pensText)}</text>`);
|
|
466
|
+
if (byeText)
|
|
467
|
+
rowParts.push(`<text class="tb-muted" x="${num(right - (scoreW + pensW) - (scoreW || pensW ? 6 : 0))}" y="${num(cy + (font - 2) * 0.35)}" font-size="${num(Math.max(font - 2, 8))}" font-style="italic" text-anchor="end">${esc(byeText)}</text>`);
|
|
468
|
+
parts.push(`<g class="${cls(k)}">${rowParts.join("")}</g>`);
|
|
469
|
+
});
|
|
470
|
+
parts.push(`<line class="tb-line" x1="${num(x + 4)}" x2="${num(x + w - 4)}" y1="${num(y + rowH)}" y2="${num(y + rowH)}" stroke-opacity=".35"${o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
|
|
471
|
+
if (noteH) {
|
|
472
|
+
const f = 9.5;
|
|
473
|
+
parts.push(`<text class="tb-muted" x="${num(x + w / 2)}" y="${num(y + h - 4)}" font-size="${f}" text-anchor="middle">${esc(fit(notes.join(" · "), f, w - 8))}</text>`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}`;
|
|
477
|
+
return `<g class="${cls2}"><title>${esc(describeTie(tie, words))}</title>${parts.join("")}</g>`;
|
|
478
|
+
}
|
package/dist/rounds.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BracketLayout, DrawOptions } from "./types.ts";
|
|
2
|
+
import type { Words } from "./types.ts";
|
|
3
|
+
/** The name of a round that has `remaining` teams left in it, counting from the final: Final, Semi-finals, Quarter-finals, Round of 16… */
|
|
4
|
+
export declare function roundName(remaining: number): string;
|
|
5
|
+
/** The generic names of `count` rounds, first round first: `roundNamesFor(3)` is Quarter-finals, Semi-finals, Final. */
|
|
6
|
+
export declare function roundNamesFor(count: number): string[];
|
|
7
|
+
/** The name of round `round` (1 is the first) of a layout, from the names given (which may be only the last rounds) or the generic ones. */
|
|
8
|
+
export declare function roundLabel(layout: BracketLayout, round: number, names: DrawOptions["roundNames"], words: Words): string;
|
package/dist/rounds.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { fill } from "./words.js";
|
|
2
|
+
/** The name of a round that has `remaining` teams left in it, counting from the final: Final, Semi-finals, Quarter-finals, Round of 16… */
|
|
3
|
+
export function roundName(remaining) {
|
|
4
|
+
if (remaining <= 2)
|
|
5
|
+
return "Final";
|
|
6
|
+
if (remaining === 4)
|
|
7
|
+
return "Semi-finals";
|
|
8
|
+
if (remaining === 8)
|
|
9
|
+
return "Quarter-finals";
|
|
10
|
+
return `Round of ${remaining}`;
|
|
11
|
+
}
|
|
12
|
+
/** The generic names of `count` rounds, first round first: `roundNamesFor(3)` is Quarter-finals, Semi-finals, Final. */
|
|
13
|
+
export function roundNamesFor(count) {
|
|
14
|
+
return Array.from({ length: count }, (_, i) => roundName(2 ** (count - i)));
|
|
15
|
+
}
|
|
16
|
+
/** The name of round `round` (1 is the first) of a layout, from the names given (which may be only the last rounds) or the generic ones. */
|
|
17
|
+
export function roundLabel(layout, round, names, words) {
|
|
18
|
+
const list = names ? ("rounds" in names ? names.rounds.map((r) => r.name) : names) : null;
|
|
19
|
+
const from = layout.rounds - round; // 0 for the final
|
|
20
|
+
const named = list?.[list.length - 1 - from];
|
|
21
|
+
if (named)
|
|
22
|
+
return named;
|
|
23
|
+
const generic = 2 ** (from + 1);
|
|
24
|
+
const remaining = layout.rounds > 7 ? 0 : generic;
|
|
25
|
+
return remaining ? roundName(remaining) : fill(words.round, { n: round });
|
|
26
|
+
}
|