@johnmorrisdotca/taisen-brackets 0.1.1 → 0.2.1

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/dist/matchup.d.ts CHANGED
@@ -1,11 +1,14 @@
1
- import type { Matchup, SideId, Team, Tie, Words } from "./types.ts";
1
+ import type { Matchup, SideId, Team, Tie } from "./types.ts";
2
+ import { type Voice } from "./locales.ts";
2
3
  /** A bracket's teams: the list given, plus a team made from every id that a matchup names and the list does not. */
3
4
  export declare function collectTeams(matchups: readonly Matchup[], given?: readonly Team[]): Map<SideId, Team>;
4
5
  /** One matchup as a tie: sides resolved to teams, the winner and the result worked out. The round is set by the layout. */
5
6
  export declare function resolveMatchup(m: Matchup, index: number, teams: ReadonlyMap<SideId, Team>): Tie;
6
7
  /** The short name of a team for a small box. */
7
- export declare const abbrOf: (team: Team | null, words: Words) => string;
8
- /** The pieces of a note under a tie's score: "a.e.t.", "pens", "in 7", "agg.". */
9
- export declare function noteParts(tie: Tie, words: Words): string[];
10
- /** A tie in a sentence, for a tooltip, a table and a description: "Argentina 3, France 3 (a.e.t., pens 4-2). Winner: Argentina". */
11
- export declare function describeTie(tie: Tie, words: Words): string;
8
+ export declare const abbrOf: (team: Team | null, v: Voice) => string;
9
+ /** The pieces of a note under a tie's score, short: "a.e.t.", "pens", "in 7", "agg.". */
10
+ export declare function noteParts(tie: Tie, v: Voice): string[];
11
+ /** The notes of a tie written out, for a sentence or a table: "after extra time", "4–2 on penalties". */
12
+ export declare function noteLong(tie: Tie, v: Voice): string[];
13
+ /** A tie in a sentence, for a tooltip, a table and a description: "Argentina 3, France 3 (after extra time, 4–2 on penalties). Winner: Argentina". */
14
+ export declare function describeTie(tie: Tie, v: Voice): string;
package/dist/matchup.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { abbreviate } from "./text.js";
2
- import { fill } from "./words.js";
2
+ import { fill } from "./locales.js";
3
3
  /** A bracket's teams: the list given, plus a team made from every id that a matchup names and the list does not. */
4
4
  export function collectTeams(matchups, given = []) {
5
5
  const teams = new Map();
@@ -34,8 +34,10 @@ function settle(m, kind) {
34
34
  const byPens = (pens) => (pens ? byScore(pens[0], pens[1]) : null);
35
35
  if (kind === "series") {
36
36
  let wins = null;
37
+ // A game level at the end of play but settled by a shoot-out is won by the side that won the shoot-out (MLS's best-of-three round).
38
+ const won = (g) => byScore(g.score[0], g.score[1]) ?? byPens(g.pens ?? null);
37
39
  if (games.length)
38
- wins = [games.filter((g) => g.score[0] > g.score[1]).length, games.filter((g) => g.score[1] > g.score[0]).length];
40
+ wins = [games.filter((g) => won(g) === 0).length, games.filter((g) => won(g) === 1).length];
39
41
  else if (m.score)
40
42
  wins = m.score;
41
43
  const need = bestOf ? Math.ceil(bestOf / 2) : null;
@@ -98,50 +100,79 @@ export function resolveMatchup(m, index, teams) {
98
100
  date: m.date ?? m.games?.[0]?.date ?? m.legs?.[0]?.date ?? "",
99
101
  no: m.no ?? null,
100
102
  third: !!m.third,
103
+ walkover: !!m.walkover,
101
104
  note: m.note ?? null,
102
105
  };
103
106
  }
104
107
  /** The short name of a team for a small box. */
105
- export const abbrOf = (team, words) => (team ? team.abbr ?? abbreviate(team.name) : words.tbd);
106
- /** The pieces of a note under a tie's score: "a.e.t.", "pens", "in 7", "agg.". */
107
- export function noteParts(tie, words) {
108
+ export const abbrOf = (team, v) => (team ? team.abbr ?? abbreviate(v.team(team)) : v.words.tbd);
109
+ /** The pieces of a note under a tie's score, short: "a.e.t.", "pens", "in 7", "agg.". */
110
+ export function noteParts(tie, v) {
108
111
  const r = tie.result;
112
+ const w = v.words;
109
113
  const out = [];
110
114
  if (r.kind === "series") {
111
115
  if (r.decided && r.played > 0)
112
- out.push(fill(words.inGames, { n: r.played }));
116
+ out.push(fill(w.inGames, { n: v.number(r.played) }));
113
117
  else if (r.bestOf)
114
- out.push(fill(words.bestOf, { n: r.bestOf }));
118
+ out.push(fill(w.bestOf, { n: v.number(r.bestOf), w: v.number(Math.ceil(r.bestOf / 2)) }));
115
119
  }
116
120
  else {
117
121
  if (r.kind === "aggregate")
118
- out.push(words.aggregate);
122
+ out.push(w.aggregate);
119
123
  if (r.kind === "replay")
120
- out.push(words.replay);
124
+ out.push(w.replay);
121
125
  if (r.extraTime)
122
- out.push(words.aet);
126
+ out.push(w.aet);
123
127
  if (r.pens)
124
- out.push(words.pens);
128
+ out.push(w.pens);
125
129
  }
130
+ if (tie.walkover)
131
+ out.push(w.walkover);
126
132
  if (tie.note)
127
133
  out.push(tie.note);
128
134
  return out;
129
135
  }
130
- /** A tie in a sentence, for a tooltip, a table and a description: "Argentina 3, France 3 (a.e.t., pens 4-2). Winner: Argentina". */
131
- export function describeTie(tie, words) {
132
- const name = (t) => (t ? t.name : words.tbd);
133
- const [a, b] = tie.sides;
136
+ /** The notes of a tie written out, for a sentence or a table: "after extra time", "4–2 on penalties". */
137
+ export function noteLong(tie, v) {
134
138
  const r = tie.result;
135
- let text;
136
- if (!r.score)
137
- text = `${name(a)} ${words.versus} ${name(b)}`;
139
+ const w = v.words;
140
+ const out = [];
141
+ if (r.kind === "series") {
142
+ if (r.decided && r.played > 0)
143
+ out.push(fill(w.inGames, { n: v.number(r.played) }));
144
+ else if (r.bestOf)
145
+ out.push(fill(w.bestOf, { n: v.number(r.bestOf), w: v.number(Math.ceil(r.bestOf / 2)) }));
146
+ }
138
147
  else {
139
- text = `${name(a)} ${r.score[0]}, ${name(b)} ${r.score[1]}`;
140
- const extra = noteParts(tie, words).map((part) => (part === words.pens && r.pens ? `${words.pens} ${r.pens[0]}-${r.pens[1]}` : part));
141
- if (extra.length)
142
- text += ` (${extra.join(", ")})`;
148
+ if (r.kind === "aggregate")
149
+ out.push(w.aggregateLong);
150
+ if (r.kind === "replay")
151
+ out.push(w.replay);
152
+ if (r.extraTime)
153
+ out.push(w.aetLong);
154
+ if (r.pens)
155
+ out.push(fill(w.pensScore, { a: v.number(r.pens[0]), b: v.number(r.pens[1]) }));
143
156
  }
157
+ if (tie.walkover)
158
+ out.push(w.walkover);
159
+ if (tie.note)
160
+ out.push(tie.note);
161
+ return out;
162
+ }
163
+ /** A tie in a sentence, for a tooltip, a table and a description: "Argentina 3, France 3 (after extra time, 4–2 on penalties). Winner: Argentina". */
164
+ export function describeTie(tie, v) {
165
+ const w = v.words;
166
+ const name = (t) => (t ? v.team(t) : w.tbd);
167
+ const [a, b] = tie.sides;
168
+ const r = tie.result;
169
+ let text = r.score
170
+ ? fill(w.tieScore, { a: name(a), b: name(b), sa: v.number(r.score[0]), sb: v.number(r.score[1]) })
171
+ : fill(w.tieVs, { a: name(a), b: name(b) });
172
+ const notes = noteLong(tie, v);
173
+ if (notes.length)
174
+ text += fill(w.note, { notes: notes.join(w.noteJoin) });
144
175
  if (tie.winner !== null)
145
- text += `. ${words.winner}: ${name(tie.sides[tie.winner])}`;
176
+ text += `${w.sep}${fill(w.winnerIs, { name: name(tie.sides[tie.winner]) })}`;
146
177
  return text;
147
178
  }
package/dist/presets.d.ts CHANGED
@@ -2,9 +2,9 @@
2
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
3
  * American leagues and the FIFA World Cup. Data only; nothing here draws. Each era was checked against the English
4
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.
5
+ * its own entry. The year is the year the playoffs are played in (the NHL's and the NBA's 2024 are the 2024 playoffs, of the 2023-24 season), except for the NFL, whose year is its season's: 2024 is the season played in 2024, with playoffs in January and February 2025.
6
6
  */
7
- export type League = "nhl" | "nba" | "mlb" | "nfl" | "cfl" | "worldcup";
7
+ export type League = "nhl" | "nba" | "mlb" | "nfl" | "cfl" | "mls" | "worldcup";
8
8
  export interface PlayoffRound {
9
9
  /** The round's name as the league calls it. */
10
10
  name: string;
package/dist/presets.js CHANGED
@@ -2,7 +2,7 @@
2
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
3
  * American leagues and the FIFA World Cup. Data only; nothing here draws. Each era was checked against the English
4
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.
5
+ * its own entry. The year is the year the playoffs are played in (the NHL's and the NBA's 2024 are the 2024 playoffs, of the 2023-24 season), except for the NFL, whose year is its season's: 2024 is the season played in 2024, with playoffs in January and February 2025.
6
6
  */
7
7
  /** The address of English Wikipedia articles, by title. */
8
8
  const wiki = (...titles) => titles.map((t) => `https://en.wikipedia.org/wiki/${t.replace(/ /g, "_")}`);
@@ -47,6 +47,9 @@ const ERAS = {
47
47
  cfl: [
48
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
49
  ],
50
+ mls: [
51
+ { league: "mls", from: 2023, to: null, label: "18 teams, nine a conference: a wild card match, then best of three", teams: 18, byes: 7, groups: WEST_EAST, rounds: [r("Wild Card Round", 1), r("Round One", 3), r("Conference Semifinals", 1), r("Conference Finals", 1), r("MLS Cup", 1)], note: "The teams ranked 8th and 9th of each conference play one wild card match (settled by a shoot-out if level); the other seven teams skip it. Round One is a best-of-three series (a game level after play is settled by a shoot-out, and counts as a win); the rest are single matches. Earlier seasons had other formats and are not covered.", sources: wiki("MLS Cup Playoffs", "2024 MLS Cup Playoffs") },
52
+ ],
50
53
  worldcup: [
51
54
  { 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
55
  { 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") },
package/dist/render.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { BracketInput, BracketLayout, DrawOptions, Matchup, Size, SizeName, Words } from "./types.ts";
1
+ import type { BracketInput, BracketLayout, DrawOptions, Matchup, Size, SizeName } from "./types.ts";
2
+ import { type Voice } from "./locales.ts";
2
3
  /** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs. */
3
4
  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;
5
+ /** The layout with each team's name in the language: what the drawing, the table and the description are written from. */
6
+ export declare function localizedLayout(layout: BracketLayout, v: Voice): BracketLayout;
7
+ /** The text a screen reader gets for the drawing as a whole, in the language of `locale`. */
8
+ export declare function describeBracket(input: BracketLayout | readonly Matchup[] | BracketInput, options?: Pick<DrawOptions, "locale" | "words" | "teamName" | "roundNames">): string;
9
9
  /**
10
10
  * Draws a bracket as an SVG string. `input` is a list of matchups, `{ matchups, teams }` or a layout from `layoutBracket`.
11
11
  * The same input and options always give the same string: no clock, no random number, no counter.
package/dist/render.js CHANGED
@@ -4,7 +4,7 @@ import { cleanSvg, clipId, drawMark, safeUrl } from "./marks.js";
4
4
  import { roundLabel } from "./rounds.js";
5
5
  import { styleFor } from "./theme.js";
6
6
  import { esc, fit, hash, num, textWidth } from "./text.js";
7
- import { fill, wordsWith } from "./words.js";
7
+ import { fill, voiceOf } from "./locales.js";
8
8
  /** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs. */
9
9
  export const SIZES = Object.freeze({
10
10
  wide: { width: 1200, height: 675 },
@@ -13,34 +13,41 @@ export const SIZES = Object.freeze({
13
13
  });
14
14
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
15
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. */
16
+ /** The layout with each team's name in the language: what the drawing, the table and the description are written from. */
17
+ export function localizedLayout(layout, v) {
18
+ const named = new Map(layout.teams.map((t) => [t.id, { ...t, name: v.team(t) }]));
19
+ const swap = (t) => (t ? (named.get(t.id) ?? t) : t);
20
+ return { ...layout, ties: layout.ties.map((t) => ({ ...t, sides: [swap(t.sides[0]), swap(t.sides[1])] })), champion: swap(layout.champion), teams: [...named.values()] };
21
+ }
22
+ /** The text a screen reader gets for the drawing as a whole, in the language of `locale`. */
17
23
  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 })];
24
+ const v = voiceOf(options);
25
+ const layout = localizedLayout(isLayout(input) ? input : layoutBracket(input), v);
26
+ const w = v.words;
27
+ const parts = [fill(layout.rounds === 1 ? w.summaryOne : w.summary, { n: v.number(layout.teams.length), r: v.number(layout.rounds) })];
21
28
  const finalTie = layout.finalIndex !== null ? layout.ties[layout.finalIndex] : null;
22
29
  if (finalTie)
23
- parts.push(`${roundLabel(layout, layout.rounds, options.roundNames, words)}: ${describeTie(finalTie, words)}`);
30
+ parts.push(fill(w.finalLine, { round: roundLabel(layout, layout.rounds, options.roundNames, v), tie: describeTie(finalTie, v) }));
24
31
  if (layout.champion)
25
- parts.push(`${words.champion}: ${layout.champion.name}`);
26
- return `${parts.join(". ")}.`;
32
+ parts.push(fill(w.championIs, { name: layout.champion.name }));
33
+ return `${parts.join(w.sep)}${w.end}`;
27
34
  }
28
35
  /**
29
36
  * Draws a bracket as an SVG string. `input` is a list of matchups, `{ matchups, teams }` or a layout from `layoutBracket`.
30
37
  * The same input and options always give the same string: no clock, no random number, no counter.
31
38
  */
32
39
  export function drawBracket(input, options = {}) {
33
- const layout = isLayout(input) ? input : layoutBracket(input, options);
34
- const words = wordsWith(options.words);
40
+ const v = voiceOf(options);
41
+ const layout = localizedLayout(isLayout(input) ? input : layoutBracket(input, options), v);
35
42
  const theme = options.theme ?? "auto";
36
43
  const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
37
44
  const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
38
45
  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]))}`;
46
+ const prefix = options.id ?? `tb-${hash(JSON.stringify([v.locale.code, layout.teams.map((t) => t.name), 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
47
  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;
48
+ const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx);
49
+ const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx)); // draws the marks, so before the defs are written
50
+ const title = options.title ?? v.words.title;
44
51
  const desc = describeBracket(layout, options);
45
52
  const defs = [
46
53
  ...[...ctx.radii].sort((a, b) => a - b).map((r) => `<clipPath id="${clipId(prefix, r)}"><circle r="${num(r)}"/></clipPath>`),
@@ -49,7 +56,7 @@ export function drawBracket(input, options = {}) {
49
56
  const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme)}</style>`;
50
57
  const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>`;
51
58
  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">`,
59
+ `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" 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
60
  `<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
54
61
  style,
55
62
  defs ? `<defs>${defs}</defs>` : "",
@@ -62,7 +69,7 @@ export function drawBracket(input, options = {}) {
62
69
  ].filter(Boolean).join("\n");
63
70
  }
64
71
  // ---------------------------------------------------------------------------------------------- the plan
65
- function planBracket(layout, o, words, size, vertical, ctx) {
72
+ function planBracket(layout, o, v, size, vertical, ctx) {
66
73
  const pad = vertical ? 14 : 22;
67
74
  const showLabels = o.roundLabels ?? true;
68
75
  const poster = o.theme === "poster";
@@ -130,7 +137,11 @@ function planBracket(layout, o, words, size, vertical, ctx) {
130
137
  const rowH = h / 2;
131
138
  const font = clamp(rowH * 0.5, 8.5, 16);
132
139
  const md = clamp(rowH * 0.7, 10, 28);
133
- const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", font + 1) + 14;
140
+ // Room for the score, and for the words some boxes carry beside it: a bye, a shoot-out's score.
141
+ const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
142
+ const anyPens = layout.ties.some((t) => t.result.pens !== null);
143
+ const extra = (anyBye ? textWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? textWidth("(00)", Math.max(font - 2, 8)) + 3 : 0);
144
+ const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", font + 1) + extra + 14;
134
145
  const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
135
146
  return named === "mark" && o.markShape === "none" ? "compact" : named;
136
147
  };
@@ -181,24 +192,24 @@ function planBracket(layout, o, words, size, vertical, ctx) {
181
192
  for (const b of sd.boxes) {
182
193
  const rect = place(sd.key, b);
183
194
  rects.set(b.i, rect);
184
- boxes.push({ rect, tie: layout.ties[b.i], order: b.order, bye: b.bye, final: false, champion: false });
195
+ boxes.push({ rect, tie: layout.ties[b.i], order: b.order, bye: b.bye, byes: b.byes, final: false, champion: false });
185
196
  }
186
197
  const midX = (halves ? starts[nc] : starts[nc] ?? 0) + (halves ? centreT : 0) / 2;
187
198
  if (halves && layout.final) {
188
199
  const fy = treeY0 + layout.final.y * rowStep + rowStep / 2;
189
200
  const rect = { x: midX - finalW / 2, y: fy - finalH / 2, w: finalW, h: finalH };
190
201
  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 });
202
+ boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, byes: [], final: true, champion: champ });
192
203
  if (showLabels)
193
- texts.push(label(roundLabel(layout, layout.rounds, o.roundNames, words), midX, rect.y - 7, poster, "middle"));
204
+ texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
194
205
  if (champ)
195
- extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, words, ctx, poster));
206
+ extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, v, ctx, poster));
196
207
  if (withThird) {
197
208
  const t3 = layout.ties[layout.third];
198
209
  const r3 = { x: midX - finalW / 2, y: rect.y + rect.h + 14 + 18, w: finalW, h: boxH };
199
210
  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 });
211
+ texts.push(label(v, v.words.thirdPlace, midX, r3.y - 6, "middle"));
212
+ boxes.push({ rect: r3, tie: t3, order: [0, 1], bye: null, byes: [], final: false, champion: false });
202
213
  }
203
214
  }
204
215
  else if (!halves) {
@@ -208,17 +219,17 @@ function planBracket(layout, o, words, size, vertical, ctx) {
208
219
  if (fb && champ) {
209
220
  const cx = starts[k] + sizes[k] / 2;
210
221
  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));
222
+ extras.push(...championBlock(layout.champion, cx, fy - champH / 2, champMark, trophyH, nameFont, o, v, ctx, poster));
212
223
  }
213
224
  }
214
225
  if (showLabels) {
215
226
  for (const c of layout.columns) {
216
- const name = roundLabel(layout, c, o.roundNames, words);
227
+ const name = roundLabel(layout, c, o.roundNames, v);
217
228
  const ci = layout.columns.indexOf(c);
218
229
  const ks = halves ? [slotOfColumn("l", ci), slotOfColumn("r", ci)] : [ci];
219
230
  const used = layout.sides.length > 1 || !halves ? (halves ? [0, 1] : [0]) : [0];
220
231
  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)); });
232
+ texts.push(label(v, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
222
233
  }
223
234
  }
224
235
  if (hasGroups) {
@@ -227,7 +238,7 @@ function planBracket(layout, o, words, size, vertical, ctx) {
227
238
  return;
228
239
  const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
229
240
  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>`);
241
+ 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 ? v.upper(sd.label) : sd.label)}</text>`);
231
242
  });
232
243
  }
233
244
  // lines
@@ -245,9 +256,9 @@ function planBracket(layout, o, words, size, vertical, ctx) {
245
256
  links.push(`<path class="tb-line" d="M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1)}"${lineAttrs(o)}/>`);
246
257
  }
247
258
  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));
259
+ thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, boxes, texts);
260
+ othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, boxes, texts);
261
+ texts.push(...headings(v, o, width, pad, poster));
251
262
  return { width, height, boxes, links, texts, extras, detail, vertical };
252
263
  }
253
264
  // ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
@@ -280,32 +291,32 @@ function planBracket(layout, o, words, size, vertical, ctx) {
280
291
  const c = layout.columns.indexOf(b.round);
281
292
  const rect = { x: xOf(b.y), y: bandStart(sd.key, c), w: S, h: D };
282
293
  rects.set(b.i, rect);
283
- boxes.push({ rect, tie: layout.ties[b.i], order: b.order, bye: b.bye, final: false, champion: false });
294
+ boxes.push({ rect, tie: layout.ties[b.i], order: b.order, bye: b.bye, byes: b.byes, final: false, champion: false });
284
295
  }
285
296
  const midX = W0 / 2;
286
297
  if (halves && layout.final) {
287
298
  const cy0 = topT + bandsPerSide * bandT;
288
299
  let y = cy0;
289
300
  if (champ)
290
- extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, words, ctx, poster));
301
+ extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
291
302
  if (showLabels) {
292
- texts.push(label(roundLabel(layout, layout.rounds, o.roundNames, words), midX, y + 12, poster, "middle"));
303
+ texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, y + 12, "middle"));
293
304
  y += 16;
294
305
  }
295
306
  const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
296
307
  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 });
308
+ boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, byes: [], final: true, champion: champ });
298
309
  }
299
310
  else if (!halves && champ && layout.finalIndex !== null) {
300
311
  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));
312
+ extras.push(...championBlock(layout.champion, fr.x + fr.w / 2, fr.y + fr.h + 12, champMark, trophyH, nameFont, o, v, ctx, poster));
302
313
  }
303
314
  if (showLabels) {
304
315
  layout.columns.forEach((c, ci) => {
305
- const name = roundLabel(layout, c, o.roundNames, words);
316
+ const name = roundLabel(layout, c, o.roundNames, v);
306
317
  const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
307
318
  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>`);
319
+ 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(v.upper(name))}</text>`);
309
320
  });
310
321
  }
311
322
  if (hasGroups) {
@@ -313,7 +324,7 @@ function planBracket(layout, o, words, size, vertical, ctx) {
313
324
  if (!sd.label)
314
325
  return;
315
326
  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>`);
327
+ 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(v.upper(sd.label))}</text>`);
317
328
  });
318
329
  }
319
330
  for (const t of layout.ties) {
@@ -330,9 +341,9 @@ function planBracket(layout, o, words, size, vertical, ctx) {
330
341
  links.push(`<path class="tb-line" d="M${num(x0c)} ${num(y0)}V${num(ym)}H${num(x1c)}V${num(y1)}"${lineAttrs(o)}/>`);
331
342
  }
332
343
  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));
344
+ thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, 200), 52, v, boxes, texts);
345
+ othersBlock(layout, others, totalT - pad + thirdBlock + 6, W0, pad, ow, OTHER_H, v, boxes, texts);
346
+ texts.push(...headings(v, o, W0, pad, poster));
336
347
  return { width: W0, height, boxes, links, texts, extras, detail, vertical };
337
348
  }
338
349
  const OTHER_H = 50;
@@ -340,16 +351,14 @@ const centreMinLtr = (d) => d * 1.1;
340
351
  function lineAttrs(o) {
341
352
  return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
342
353
  }
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>`;
354
+ function label(v, text, x, y, anchor, maxWidth = 1e9) {
355
+ 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(v.upper(text), 11 * 1.18, maxWidth))}</text>`;
347
356
  }
348
- function headings(o, width, pad, poster) {
357
+ function headings(v, o, width, pad, poster) {
349
358
  const out = [];
350
359
  let y = pad + 24;
351
360
  if (o.title) {
352
- const text = poster ? o.title.toUpperCase() : o.title;
361
+ const text = poster ? v.upper(o.title) : o.title;
353
362
  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
363
  y += poster ? 24 : 22;
355
364
  }
@@ -358,7 +367,7 @@ function headings(o, width, pad, poster) {
358
367
  return out;
359
368
  }
360
369
  /** 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) {
370
+ function championBlock(team, cx, top, mark, trophyH, nameFont, o, v, ctx, poster) {
362
371
  const out = [];
363
372
  let y = top;
364
373
  if (o.trophy && trophyH) {
@@ -375,8 +384,8 @@ function championBlock(team, cx, top, mark, trophyH, nameFont, o, words, ctx, po
375
384
  if (ctx.shape !== "none")
376
385
  out.push(`<g class="tb-champ">${drawMark(team, cx, cy, mark, ctx)}</g>`);
377
386
  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>`);
387
+ out.push(`<text class="tb-name" x="${num(cx)}" y="${num(nameY)}" font-size="${num(nameFont)}" font-weight="800" text-anchor="middle">${esc(poster ? v.upper(name) : name)}</text>`);
388
+ 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(v.upper(v.words.champion))}</text>`);
380
389
  return out;
381
390
  }
382
391
  /** 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. */
@@ -389,24 +398,24 @@ function othersHeight(count, width, pad, ow, oh) {
389
398
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
390
399
  return 26 + Math.ceil(count / cols) * (oh + 10) + 6;
391
400
  }
392
- function othersBlock(layout, list, y0, width, pad, ow, oh, words, poster, boxes, texts) {
401
+ function othersBlock(layout, list, y0, width, pad, ow, oh, v, boxes, texts) {
393
402
  if (!list.length)
394
403
  return;
395
404
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
396
405
  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"));
406
+ texts.push(label(v, v.words.other, width / 2, y0 + 12, "middle"));
398
407
  list.forEach((i, n) => {
399
408
  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 });
409
+ boxes.push({ rect, tie: layout.ties[i], order: [0, 1], bye: null, byes: [], final: false, champion: false });
401
410
  });
402
411
  }
403
412
  /** 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 });
413
+ function thirdAtFoot(layout, y0, cx, w, h, v, boxes, texts) {
414
+ texts.push(label(v, v.words.thirdPlace, cx, y0 + 12, "middle"));
415
+ boxes.push({ rect: { x: cx - w / 2, y: y0 + 18, w, h }, tie: layout.ties[layout.third], order: [0, 1], bye: null, byes: [], final: false, champion: false });
407
416
  }
408
417
  // ---------------------------------------------------------------------------------------------- one box
409
- function drawBox(p, plan, o, words, ctx) {
418
+ function drawBox(p, plan, o, v, ctx) {
410
419
  const { rect, tie } = p;
411
420
  const { x, y, w, h } = rect;
412
421
  const detail = plan.detail;
@@ -416,7 +425,7 @@ function drawBox(p, plan, o, words, ctx) {
416
425
  const parts = [];
417
426
  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
427
  const r = tie.result;
419
- const notes = noteParts(tie, words);
428
+ const notes = noteParts(tie, v);
420
429
  const noteH = detail !== "mark" && h >= 50 && notes.length ? 13 : 0;
421
430
  const rowH = (h - noteH) / 2;
422
431
  const rowsOrder = p.order;
@@ -446,19 +455,25 @@ function drawBox(p, plan, o, words, ctx) {
446
455
  rowParts.push(drawMark(team, x + 6 + md / 2, cy, md, ctx));
447
456
  tx = x + 6 + md + 5;
448
457
  }
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;
458
+ const score = scoresOn && r.score ? v.number(r.score[k]) : "";
459
+ const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
460
+ const bye = p.byes.includes(k);
452
461
  const right = x + w - 6;
453
462
  const scoreW = score ? textWidth(score, font + 1) : 0;
454
463
  const pensW = pensText ? textWidth(pensText, font - 2) + 3 : 0;
455
- const byeText = bye ? words.bye : "";
464
+ const seed = seedsOn && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
465
+ const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
466
+ // The word for a bye goes beside the score only where the name still fits beside it.
467
+ const byeFits = bye && right - tx - (scoreW + pensW) - (textWidth(v.words.bye, Math.max(font - 2, 8)) + 6) - 6 >= textWidth(`${seed}${nm}`, font);
468
+ const byeText = byeFits ? v.words.bye : "";
456
469
  const byeW = byeText ? textWidth(byeText, Math.max(font - 2, 8)) : 0;
457
470
  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>`);
471
+ // A name that does not fit is set a little smaller before it is cut.
472
+ let nameFont = font;
473
+ while (nameFont > 9.5 && textWidth(`${seed}${nm}`, nameFont) > avail)
474
+ nameFont -= 0.5;
475
+ const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8));
476
+ rowParts.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(tx)}" y="${num(cy + nameFont * 0.35)}" font-size="${num(nameFont)}">${esc(text)}</text>`);
462
477
  if (score)
463
478
  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
479
  if (pensText)
@@ -474,5 +489,5 @@ function drawBox(p, plan, o, words, ctx) {
474
489
  }
475
490
  }
476
491
  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>`;
492
+ return `<g class="${cls2}"><title>${esc(describeTie(tie, v))}</title>${parts.join("")}</g>`;
478
493
  }
package/dist/rounds.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { BracketLayout, DrawOptions } from "./types.ts";
2
- import type { Words } from "./types.ts";
2
+ import { type Voice } from "./locales.ts";
3
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
4
  export declare function roundName(remaining: number): string;
5
5
  /** The generic names of `count` rounds, first round first: `roundNamesFor(3)` is Quarter-finals, Semi-finals, Final. */
6
6
  export declare function roundNamesFor(count: number): string[];
7
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;
8
+ export declare function roundLabel(layout: BracketLayout, round: number, names: DrawOptions["roundNames"], v: Voice): string;
package/dist/rounds.js CHANGED
@@ -1,4 +1,4 @@
1
- import { fill } from "./words.js";
1
+ import { fill } from "./locales.js";
2
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
3
  export function roundName(remaining) {
4
4
  if (remaining <= 2)
@@ -14,13 +14,13 @@ export function roundNamesFor(count) {
14
14
  return Array.from({ length: count }, (_, i) => roundName(2 ** (count - i)));
15
15
  }
16
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) {
17
+ export function roundLabel(layout, round, names, v) {
18
18
  const list = names ? ("rounds" in names ? names.rounds.map((r) => r.name) : names) : null;
19
19
  const from = layout.rounds - round; // 0 for the final
20
20
  const named = list?.[list.length - 1 - from];
21
21
  if (named)
22
- return named;
22
+ return v.round(named);
23
23
  const generic = 2 ** (from + 1);
24
24
  const remaining = layout.rounds > 7 ? 0 : generic;
25
- return remaining ? roundName(remaining) : fill(words.round, { n: round });
25
+ return remaining ? v.round(roundName(remaining)) : fill(v.words.round, { n: v.number(round) });
26
26
  }
package/dist/table.d.ts CHANGED
@@ -1,7 +1,5 @@
1
- import type { BracketInput, BracketLayout, DrawOptions, Matchup, Words } from "./types.ts";
2
- export interface TableOptions {
3
- words?: Partial<Words>;
4
- roundNames?: DrawOptions["roundNames"];
1
+ import type { BracketInput, BracketLayout, DrawOptions, Matchup } from "./types.ts";
2
+ export interface TableOptions extends Pick<DrawOptions, "locale" | "words" | "teamName" | "roundNames"> {
5
3
  /** The table's caption. Default: the summary ("16 teams, 4 rounds"). */
6
4
  caption?: string;
7
5
  /** A class for the `<table>`. Default `tb-table`. */
@@ -9,6 +7,7 @@ export interface TableOptions {
9
7
  }
10
8
  /**
11
9
  * The bracket as an HTML table, for a reader the drawing does not serve: one row for every tie, round by round, with the
12
- * two sides, the score, the winner in bold and a note. It is a plain, valid `<table>` with a caption and header cells.
10
+ * two sides, the score, the winner in bold, the date where the tie has one (written as the language writes dates) and a note.
11
+ * It is a plain, valid `<table>` with a caption and header cells, in the language of `locale`.
13
12
  */
14
13
  export declare function bracketTable(input: BracketLayout | readonly Matchup[] | BracketInput, options?: TableOptions): string;