@johnmorrisdotca/taisen-brackets 0.1.0 → 0.2.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/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();
@@ -98,50 +98,79 @@ export function resolveMatchup(m, index, teams) {
98
98
  date: m.date ?? m.games?.[0]?.date ?? m.legs?.[0]?.date ?? "",
99
99
  no: m.no ?? null,
100
100
  third: !!m.third,
101
+ walkover: !!m.walkover,
101
102
  note: m.note ?? null,
102
103
  };
103
104
  }
104
105
  /** 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) {
106
+ export const abbrOf = (team, v) => (team ? team.abbr ?? abbreviate(v.team(team)) : v.words.tbd);
107
+ /** The pieces of a note under a tie's score, short: "a.e.t.", "pens", "in 7", "agg.". */
108
+ export function noteParts(tie, v) {
108
109
  const r = tie.result;
110
+ const w = v.words;
109
111
  const out = [];
110
112
  if (r.kind === "series") {
111
113
  if (r.decided && r.played > 0)
112
- out.push(fill(words.inGames, { n: r.played }));
114
+ out.push(fill(w.inGames, { n: v.number(r.played) }));
113
115
  else if (r.bestOf)
114
- out.push(fill(words.bestOf, { n: r.bestOf }));
116
+ out.push(fill(w.bestOf, { n: v.number(r.bestOf), w: v.number(Math.ceil(r.bestOf / 2)) }));
115
117
  }
116
118
  else {
117
119
  if (r.kind === "aggregate")
118
- out.push(words.aggregate);
120
+ out.push(w.aggregate);
119
121
  if (r.kind === "replay")
120
- out.push(words.replay);
122
+ out.push(w.replay);
121
123
  if (r.extraTime)
122
- out.push(words.aet);
124
+ out.push(w.aet);
123
125
  if (r.pens)
124
- out.push(words.pens);
126
+ out.push(w.pens);
125
127
  }
128
+ if (tie.walkover)
129
+ out.push(w.walkover);
126
130
  if (tie.note)
127
131
  out.push(tie.note);
128
132
  return out;
129
133
  }
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;
134
+ /** The notes of a tie written out, for a sentence or a table: "after extra time", "4–2 on penalties". */
135
+ export function noteLong(tie, v) {
134
136
  const r = tie.result;
135
- let text;
136
- if (!r.score)
137
- text = `${name(a)} ${words.versus} ${name(b)}`;
137
+ const w = v.words;
138
+ const out = [];
139
+ if (r.kind === "series") {
140
+ if (r.decided && r.played > 0)
141
+ out.push(fill(w.inGames, { n: v.number(r.played) }));
142
+ else if (r.bestOf)
143
+ out.push(fill(w.bestOf, { n: v.number(r.bestOf), w: v.number(Math.ceil(r.bestOf / 2)) }));
144
+ }
138
145
  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(", ")})`;
146
+ if (r.kind === "aggregate")
147
+ out.push(w.aggregateLong);
148
+ if (r.kind === "replay")
149
+ out.push(w.replay);
150
+ if (r.extraTime)
151
+ out.push(w.aetLong);
152
+ if (r.pens)
153
+ out.push(fill(w.pensScore, { a: v.number(r.pens[0]), b: v.number(r.pens[1]) }));
143
154
  }
155
+ if (tie.walkover)
156
+ out.push(w.walkover);
157
+ if (tie.note)
158
+ out.push(tie.note);
159
+ return out;
160
+ }
161
+ /** 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". */
162
+ export function describeTie(tie, v) {
163
+ const w = v.words;
164
+ const name = (t) => (t ? v.team(t) : w.tbd);
165
+ const [a, b] = tie.sides;
166
+ const r = tie.result;
167
+ let text = r.score
168
+ ? fill(w.tieScore, { a: name(a), b: name(b), sa: v.number(r.score[0]), sb: v.number(r.score[1]) })
169
+ : fill(w.tieVs, { a: name(a), b: name(b) });
170
+ const notes = noteLong(tie, v);
171
+ if (notes.length)
172
+ text += fill(w.note, { notes: notes.join(w.noteJoin) });
144
173
  if (tie.winner !== null)
145
- text += `. ${words.winner}: ${name(tie.sides[tie.winner])}`;
174
+ text += `${w.sep}${fill(w.winnerIs, { name: name(tie.sides[tie.winner]) })}`;
146
175
  return text;
147
176
  }
package/dist/presets.d.ts 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
  export type League = "nhl" | "nba" | "mlb" | "nfl" | "cfl" | "worldcup";
8
8
  export interface PlayoffRound {
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, "_")}`);
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";
@@ -190,14 +197,14 @@ function planBracket(layout, o, words, size, vertical, ctx) {
190
197
  rects.set(layout.final.i, rect);
191
198
  boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, final: true, champion: champ });
192
199
  if (showLabels)
193
- texts.push(label(roundLabel(layout, layout.rounds, o.roundNames, words), midX, rect.y - 7, poster, "middle"));
200
+ texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
194
201
  if (champ)
195
- extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, words, ctx, poster));
202
+ extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, v, ctx, poster));
196
203
  if (withThird) {
197
204
  const t3 = layout.ties[layout.third];
198
205
  const r3 = { x: midX - finalW / 2, y: rect.y + rect.h + 14 + 18, w: finalW, h: boxH };
199
206
  rects.set(layout.third, r3);
200
- texts.push(label(words.thirdPlace, midX, r3.y - 6, poster, "middle"));
207
+ texts.push(label(v, v.words.thirdPlace, midX, r3.y - 6, "middle"));
201
208
  boxes.push({ rect: r3, tie: t3, order: [0, 1], bye: null, final: false, champion: false });
202
209
  }
203
210
  }
@@ -208,17 +215,17 @@ function planBracket(layout, o, words, size, vertical, ctx) {
208
215
  if (fb && champ) {
209
216
  const cx = starts[k] + sizes[k] / 2;
210
217
  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));
218
+ extras.push(...championBlock(layout.champion, cx, fy - champH / 2, champMark, trophyH, nameFont, o, v, ctx, poster));
212
219
  }
213
220
  }
214
221
  if (showLabels) {
215
222
  for (const c of layout.columns) {
216
- const name = roundLabel(layout, c, o.roundNames, words);
223
+ const name = roundLabel(layout, c, o.roundNames, v);
217
224
  const ci = layout.columns.indexOf(c);
218
225
  const ks = halves ? [slotOfColumn("l", ci), slotOfColumn("r", ci)] : [ci];
219
226
  const used = layout.sides.length > 1 || !halves ? (halves ? [0, 1] : [0]) : [0];
220
227
  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)); });
228
+ texts.push(label(v, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
222
229
  }
223
230
  }
224
231
  if (hasGroups) {
@@ -227,7 +234,7 @@ function planBracket(layout, o, words, size, vertical, ctx) {
227
234
  return;
228
235
  const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
229
236
  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>`);
237
+ 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
238
  });
232
239
  }
233
240
  // lines
@@ -245,9 +252,9 @@ function planBracket(layout, o, words, size, vertical, ctx) {
245
252
  links.push(`<path class="tb-line" d="M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1)}"${lineAttrs(o)}/>`);
246
253
  }
247
254
  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));
255
+ thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, boxes, texts);
256
+ othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, boxes, texts);
257
+ texts.push(...headings(v, o, width, pad, poster));
251
258
  return { width, height, boxes, links, texts, extras, detail, vertical };
252
259
  }
253
260
  // ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
@@ -287,9 +294,9 @@ function planBracket(layout, o, words, size, vertical, ctx) {
287
294
  const cy0 = topT + bandsPerSide * bandT;
288
295
  let y = cy0;
289
296
  if (champ)
290
- extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, words, ctx, poster));
297
+ extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
291
298
  if (showLabels) {
292
- texts.push(label(roundLabel(layout, layout.rounds, o.roundNames, words), midX, y + 12, poster, "middle"));
299
+ texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, y + 12, "middle"));
293
300
  y += 16;
294
301
  }
295
302
  const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
@@ -298,14 +305,14 @@ function planBracket(layout, o, words, size, vertical, ctx) {
298
305
  }
299
306
  else if (!halves && champ && layout.finalIndex !== null) {
300
307
  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));
308
+ extras.push(...championBlock(layout.champion, fr.x + fr.w / 2, fr.y + fr.h + 12, champMark, trophyH, nameFont, o, v, ctx, poster));
302
309
  }
303
310
  if (showLabels) {
304
311
  layout.columns.forEach((c, ci) => {
305
- const name = roundLabel(layout, c, o.roundNames, words);
312
+ const name = roundLabel(layout, c, o.roundNames, v);
306
313
  const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
307
314
  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>`);
315
+ 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
316
  });
310
317
  }
311
318
  if (hasGroups) {
@@ -313,7 +320,7 @@ function planBracket(layout, o, words, size, vertical, ctx) {
313
320
  if (!sd.label)
314
321
  return;
315
322
  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>`);
323
+ 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
324
  });
318
325
  }
319
326
  for (const t of layout.ties) {
@@ -330,9 +337,9 @@ function planBracket(layout, o, words, size, vertical, ctx) {
330
337
  links.push(`<path class="tb-line" d="M${num(x0c)} ${num(y0)}V${num(ym)}H${num(x1c)}V${num(y1)}"${lineAttrs(o)}/>`);
331
338
  }
332
339
  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));
340
+ thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, 200), 52, v, boxes, texts);
341
+ othersBlock(layout, others, totalT - pad + thirdBlock + 6, W0, pad, ow, OTHER_H, v, boxes, texts);
342
+ texts.push(...headings(v, o, W0, pad, poster));
336
343
  return { width: W0, height, boxes, links, texts, extras, detail, vertical };
337
344
  }
338
345
  const OTHER_H = 50;
@@ -340,16 +347,14 @@ const centreMinLtr = (d) => d * 1.1;
340
347
  function lineAttrs(o) {
341
348
  return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
342
349
  }
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>`;
350
+ function label(v, text, x, y, anchor, maxWidth = 1e9) {
351
+ 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
352
  }
348
- function headings(o, width, pad, poster) {
353
+ function headings(v, o, width, pad, poster) {
349
354
  const out = [];
350
355
  let y = pad + 24;
351
356
  if (o.title) {
352
- const text = poster ? o.title.toUpperCase() : o.title;
357
+ const text = poster ? v.upper(o.title) : o.title;
353
358
  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
359
  y += poster ? 24 : 22;
355
360
  }
@@ -358,7 +363,7 @@ function headings(o, width, pad, poster) {
358
363
  return out;
359
364
  }
360
365
  /** 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) {
366
+ function championBlock(team, cx, top, mark, trophyH, nameFont, o, v, ctx, poster) {
362
367
  const out = [];
363
368
  let y = top;
364
369
  if (o.trophy && trophyH) {
@@ -375,8 +380,8 @@ function championBlock(team, cx, top, mark, trophyH, nameFont, o, words, ctx, po
375
380
  if (ctx.shape !== "none")
376
381
  out.push(`<g class="tb-champ">${drawMark(team, cx, cy, mark, ctx)}</g>`);
377
382
  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>`);
383
+ 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>`);
384
+ 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
385
  return out;
381
386
  }
382
387
  /** 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 +394,24 @@ function othersHeight(count, width, pad, ow, oh) {
389
394
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
390
395
  return 26 + Math.ceil(count / cols) * (oh + 10) + 6;
391
396
  }
392
- function othersBlock(layout, list, y0, width, pad, ow, oh, words, poster, boxes, texts) {
397
+ function othersBlock(layout, list, y0, width, pad, ow, oh, v, boxes, texts) {
393
398
  if (!list.length)
394
399
  return;
395
400
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
396
401
  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"));
402
+ texts.push(label(v, v.words.other, width / 2, y0 + 12, "middle"));
398
403
  list.forEach((i, n) => {
399
404
  const rect = { x: x0 + (n % cols) * (ow + 10), y: y0 + 24 + Math.floor(n / cols) * (oh + 10), w: ow, h: oh };
400
405
  boxes.push({ rect, tie: layout.ties[i], order: [0, 1], bye: null, final: false, champion: false });
401
406
  });
402
407
  }
403
408
  /** 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"));
409
+ function thirdAtFoot(layout, y0, cx, w, h, v, boxes, texts) {
410
+ texts.push(label(v, v.words.thirdPlace, cx, y0 + 12, "middle"));
406
411
  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
412
  }
408
413
  // ---------------------------------------------------------------------------------------------- one box
409
- function drawBox(p, plan, o, words, ctx) {
414
+ function drawBox(p, plan, o, v, ctx) {
410
415
  const { rect, tie } = p;
411
416
  const { x, y, w, h } = rect;
412
417
  const detail = plan.detail;
@@ -416,7 +421,7 @@ function drawBox(p, plan, o, words, ctx) {
416
421
  const parts = [];
417
422
  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
423
  const r = tie.result;
419
- const notes = noteParts(tie, words);
424
+ const notes = noteParts(tie, v);
420
425
  const noteH = detail !== "mark" && h >= 50 && notes.length ? 13 : 0;
421
426
  const rowH = (h - noteH) / 2;
422
427
  const rowsOrder = p.order;
@@ -446,17 +451,17 @@ function drawBox(p, plan, o, words, ctx) {
446
451
  rowParts.push(drawMark(team, x + 6 + md / 2, cy, md, ctx));
447
452
  tx = x + 6 + md + 5;
448
453
  }
449
- const score = scoresOn && r.score ? String(r.score[k]) : "";
450
- const pensText = scoresOn && r.pens ? `(${r.pens[k]})` : "";
454
+ const score = scoresOn && r.score ? v.number(r.score[k]) : "";
455
+ const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
451
456
  const bye = p.bye === k;
452
457
  const right = x + w - 6;
453
458
  const scoreW = score ? textWidth(score, font + 1) : 0;
454
459
  const pensW = pensText ? textWidth(pensText, font - 2) + 3 : 0;
455
- const byeText = bye ? words.bye : "";
460
+ const byeText = bye ? v.words.bye : "";
456
461
  const byeW = byeText ? textWidth(byeText, Math.max(font - 2, 8)) : 0;
457
462
  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;
463
+ const seed = seedsOn && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
464
+ const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
460
465
  const text = fit(`${seed}${nm}`, font, Math.max(avail, 8));
461
466
  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
467
  if (score)
@@ -474,5 +479,5 @@ function drawBox(p, plan, o, words, ctx) {
474
479
  }
475
480
  }
476
481
  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>`;
482
+ return `<g class="${cls2}"><title>${esc(describeTie(tie, v))}</title>${parts.join("")}</g>`;
478
483
  }
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;
package/dist/table.js CHANGED
@@ -1,34 +1,38 @@
1
1
  import { layoutBracket } from "./layout.js";
2
- import { noteParts } from "./matchup.js";
2
+ import { noteLong } from "./matchup.js";
3
+ import { fill, voiceOf } from "./locales.js";
4
+ import { localizedLayout } from "./render.js";
3
5
  import { roundLabel } from "./rounds.js";
4
6
  import { esc } from "./text.js";
5
- import { fill, wordsWith } from "./words.js";
6
7
  /**
7
8
  * The bracket as an HTML table, for a reader the drawing does not serve: one row for every tie, round by round, with the
8
- * two sides, the score, the winner in bold and a note. It is a plain, valid `<table>` with a caption and header cells.
9
+ * two sides, the score, the winner in bold, the date where the tie has one (written as the language writes dates) and a note.
10
+ * It is a plain, valid `<table>` with a caption and header cells, in the language of `locale`.
9
11
  */
10
12
  export function bracketTable(input, options = {}) {
11
- const layout = "ties" in input && "rounds" in input ? input : layoutBracket(input);
12
- const words = wordsWith(options.words);
13
- const caption = options.caption ?? fill(layout.rounds === 1 ? words.summaryOne : words.summary, { n: layout.teams.length, r: layout.rounds });
13
+ const v = voiceOf(options);
14
+ const layout = localizedLayout("ties" in input && "rounds" in input ? input : layoutBracket(input), v);
15
+ const w = v.words;
16
+ const caption = options.caption ?? fill(layout.rounds === 1 ? w.summaryOne : w.summary, { n: v.number(layout.teams.length), r: v.number(layout.rounds) });
14
17
  const order = layout.ties
15
18
  .filter((t) => t.round > 0 || layout.robin.includes(t.index))
16
19
  .sort((a, b) => Number(a.third) - Number(b.third) || a.round - b.round || (a.date < b.date ? -1 : a.date > b.date ? 1 : a.index - b.index));
20
+ const dated = order.some((t) => v.date(t.date) !== null);
17
21
  const name = (t, k) => {
18
22
  const team = t.sides[k];
19
- const text = team ? esc(team.name) : esc(words.tbd);
23
+ const text = team ? esc(team.name) : esc(w.tbd);
20
24
  return t.winner === k ? `<strong>${text}</strong>` : text;
21
25
  };
22
- const score = (t) => (t.result.score ? `${t.result.score[0]}–${t.result.score[1]}` : "");
26
+ const score = (t) => (t.result.score ? fill(w.scoreText, { a: v.number(t.result.score[0]), b: v.number(t.result.score[1]) }) : "");
23
27
  const rows = order.map((t) => {
24
- const roundText = t.third ? words.thirdPlace : t.round === 0 ? "" : roundLabel(layout, t.round, options.roundNames, words);
25
- const note = noteParts(t, words).map((part) => (part === words.pens && t.result.pens ? `${words.pens} ${t.result.pens[0]}–${t.result.pens[1]}` : part)).join(", ");
26
- return `<tr><th scope="row">${esc(roundText)}</th><td>${name(t, 0)}</td><td>${esc(score(t))}</td><td>${name(t, 1)}</td><td>${esc(note)}</td></tr>`;
28
+ const roundText = t.third ? w.thirdPlace : t.round === 0 ? "" : roundLabel(layout, t.round, options.roundNames, v);
29
+ const date = dated ? `<td>${esc(v.date(t.date) ?? "")}</td>` : "";
30
+ return `<tr><th scope="row">${esc(roundText)}</th>${date}<td>${name(t, 0)}</td><td>${esc(score(t))}</td><td>${name(t, 1)}</td><td>${esc(noteLong(t, v).join(w.noteJoin))}</td></tr>`;
27
31
  });
28
32
  return [
29
- `<table class="${esc(options.className ?? "tb-table")}">`,
33
+ `<table class="${esc(options.className ?? "tb-table")}" lang="${esc(v.locale.code)}">`,
30
34
  `<caption>${esc(caption)}</caption>`,
31
- `<thead><tr><th scope="col">${esc(words.columnRound)}</th><th scope="col" colspan="3">${esc(words.columnTie)}</th><th scope="col">${esc(words.columnNote)}</th></tr></thead>`,
35
+ `<thead><tr><th scope="col">${esc(w.columnRound)}</th>${dated ? `<th scope="col">${esc(w.columnDate)}</th>` : ""}<th scope="col" colspan="3">${esc(w.columnTie)}</th><th scope="col">${esc(w.columnNote)}</th></tr></thead>`,
32
36
  `<tbody>${rows.join("")}</tbody>`,
33
37
  "</table>",
34
38
  ].join("\n");
package/dist/theme.js CHANGED
@@ -4,7 +4,7 @@ export const THEMES = Object.freeze({
4
4
  dark: { bg: "#141614", ink: "#ece8dc", muted: "#9aa095", line: "#6c7168", box: "#1e211d", edge: "#3b403a", win: "#e0705a", gold: "#e0b43b", plain: "#3b403a" },
5
5
  poster: { bg: "#0a1130", ink: "#f6f1e3", muted: "#a9b2d3", line: "#d9b24c", box: "#111b47", edge: "#8e7632", win: "#f0c64a", gold: "#f0c64a", plain: "#26336b" },
6
6
  });
7
- const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
7
+ const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Hiragino Sans", "Yu Gothic UI", "Noto Sans CJK JP", Meiryo, sans-serif';
8
8
  /** The rules for one palette, each colour read from its custom property with the palette's value as the fallback. */
9
9
  function rules(p, poster, withFont) {
10
10
  const v = (name) => `var(--tb-${name}, ${p[name]})`;