@johnmorrisdotca/taisen-brackets 0.4.0 → 0.6.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/render.js CHANGED
@@ -3,8 +3,16 @@ import { abbrOf, describeTie, noteParts } from "./matchup.js";
3
3
  import { cleanSvg, drawEmblem, drawMark, hasTrophy as hasTrophyOf, isEmblemId, markDefs, safeHref, safeUrl } from "./marks.js";
4
4
  import { roundLabel } from "./rounds.js";
5
5
  import { styleFor } from "./theme.js";
6
- import { esc, fit, hash, num, textWidth } from "./text.js";
6
+ import { resolveBackground } from "./background.js";
7
+ import { emWidth, esc, fit, hash, num } from "./text.js";
7
8
  import { fill, voiceOf } from "./locales.js";
9
+ import { paintVariant, seriesLineWidth } from "./layouts.js";
10
+ import { drawGroups } from "./groups.js";
11
+ import { headTile } from "./furniture.js";
12
+ import { drawPlayIn } from "./playin.js";
13
+ /** The names of the layouts, the tree first. */
14
+ export const LAYOUTS = Object.freeze(["tree", "rows", "shields", "update", "columns", "playin", "series", "groups"]);
15
+ const VARIANTS = ["rows", "shields", "update", "columns", "series"];
8
16
  /** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs; a card is 1080 by 1350, a tall post. */
9
17
  export const SIZES = Object.freeze({
10
18
  wide: { width: 1200, height: 675 },
@@ -14,6 +22,43 @@ export const SIZES = Object.freeze({
14
22
  });
15
23
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
16
24
  const isLayout = (x) => typeof x === "object" && x !== null && "ties" in x && "sides" in x && "rounds" in x;
25
+ /** The width of a text in the road look, set in a bold face: a little generous, so that it stays inside its place whichever font the page falls back to. */
26
+ const boldWidth = (text, size) => emWidth(text) * size;
27
+ /** The seed tag of a side: the tie's own, else the team's tag, else its seed number; `""` for none. */
28
+ function tagOf(tie, k) {
29
+ const given = tie.seeds?.[k];
30
+ const team = tie.sides[k];
31
+ const text = given ?? team?.tag ?? team?.seed;
32
+ return text === undefined || text === null ? "" : String(text).slice(0, 4);
33
+ }
34
+ /** The classes `tieClass` asks for on a tie's group, each a word of letters, digits, `-` and `_`; a leading space for each, `""` for none. A function that throws adds none. */
35
+ function tieClassOf(o, tie) {
36
+ if (typeof o.tieClass !== "function")
37
+ return "";
38
+ let asked;
39
+ try {
40
+ asked = o.tieClass(tie);
41
+ }
42
+ catch {
43
+ return "";
44
+ }
45
+ if (typeof asked !== "string")
46
+ return "";
47
+ return asked.split(/\s+/).filter((word) => /^[A-Za-z_][\w-]*$/.test(word)).map((word) => ` ${word}`).join("");
48
+ }
49
+ /**
50
+ * Where a pill or a name of `w` wide can stand in the band `y0`..`y1` so that no connector line is within 5 of it: `pref` (its left edge) when that is clear, else the nearest clear place
51
+ * between the edges `lo` and `hi`; `null` when there is none.
52
+ */
53
+ function freeX(segs, y0, y1, w, pref, lo, hi) {
54
+ const margin = 4;
55
+ const blocks = segs.filter((s) => Math.max(s.y0, s.y1) >= y0 - 1 && Math.min(s.y0, s.y1) <= y1 + 1).map((s) => [Math.min(s.x0, s.x1) - margin, Math.max(s.x0, s.x1) + margin]);
56
+ const ok = (x) => x >= lo && x + w <= hi && blocks.every(([a, b]) => x + w <= a || x >= b);
57
+ if (ok(pref))
58
+ return pref;
59
+ const found = [lo, hi - w, ...blocks.flatMap(([a, b]) => [b, a - w])].filter(ok);
60
+ return found.length === 0 ? null : found.reduce((best, x) => (Math.abs(x - pref) < Math.abs(best - pref) ? x : best));
61
+ }
17
62
  /** The sizes of one row of a box: the name, the mark and the score. The road look has larger marks and a big, heavy score. */
18
63
  function metrics(rowH, road) {
19
64
  if (road)
@@ -57,38 +102,87 @@ export function describeBracket(input, options = {}) {
57
102
  */
58
103
  export function drawBracket(input, options = {}) {
59
104
  const v = voiceOf(options);
60
- const layout = localizedLayout(isLayout(input) ? input : layoutBracket(input, options), v);
105
+ const kind = options.layout ?? "tree";
106
+ if (!LAYOUTS.includes(kind))
107
+ throw new RangeError(`taisen-brackets: unknown layout ${JSON.stringify(String(kind))}: use one of ${LAYOUTS.join(", ")}`);
108
+ // The series card lists the east first: with no order asked for, a group named east goes before one named west.
109
+ const groupOrder = kind === "series" && options.groupOrder === undefined && !isLayout(input) ? eastFirst(input) : options.groupOrder;
110
+ const layout = localizedLayout(isLayout(input) ? input : layoutBracket(input, groupOrder ? { ...options, groupOrder } : options), v);
61
111
  const theme = options.theme ?? "auto";
62
- const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
112
+ // The series card is a tall card unless a size is asked for.
113
+ const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? (kind === "series" ? "card" : "wide") : null;
63
114
  const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
64
- const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
115
+ const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : kind === "series" || sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
65
116
  const road = options.look === "road";
66
- 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, road ? 1 : 0, options.highlightPath ?? null, options.flags ? 1 : 0]))}`;
67
- const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round", look: road ? "road" : "classic", flags: options.flags, theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit", flagCache: new Map() };
117
+ const hasBg = typeof options.background === "string" || (typeof options.background === "object" && options.background !== null);
118
+ 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, road ? 1 : 0, options.highlightPath ?? null, options.flags ? 1 : 0, ...(hasBg ? [options.background, options.vs, options.codes, options.seeds] : []), ...(options.flagHref ? ["h"] : []), ...(kind !== "tree" ? [kind, options.at, options.standings, options.qualify] : []), ...(options.headers ? ["head"] : [])]))}`;
119
+ const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round", look: road ? "road" : "classic", flags: options.flags, flagHref: options.flagHref, theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit", flagCache: new Map(), defs: new Map() };
68
120
  const path = pathOf(layout, options.highlightPath);
69
- const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path);
70
121
  const linked = { count: 0 };
71
- const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked)); // draws the marks, so before the defs are written
72
122
  const title = options.title ?? v.words.title;
73
123
  const desc = describeBracket(layout, options);
74
- const defs = markDefs(ctx);
75
- const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme, { road, links: linked.count > 0, follow: path.size > 0 })}</style>`;
76
- const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>`;
124
+ // The group tables and the play-in are not the tree: they plan and draw their own parts, and share the frame (the style, the background, the title) with it.
125
+ const own = kind === "groups" ? drawGroups(layout, options, v, size, ctx, road, linked) : kind === "playin" ? drawPlayIn(layout, options, v, size, ctx, road, linked) : null;
126
+ if (own)
127
+ return frame({ prefix, v, options, theme, road, kind, title, desc: own.desc ?? desc, width: own.width, height: own.height, followed: false, links: [], parts: [...own.body, ...own.texts], linkCount: linked.count, ctx });
128
+ const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path, kind);
129
+ const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked, layout.place[b.tie.index])); // draws the marks, so before the defs are written
130
+ return frame({ prefix, v, options, theme, road, kind, title, desc, width: plan.width, height: plan.height, followed: path.size > 0, links: plan.links, parts: [...drawn, ...plan.texts, ...plan.extras], linkCount: linked.count, ctx });
131
+ }
132
+ /** The drawing's frame: the root `<svg>` with its title, its style, the defs the marks wrote and the background, round the parts a layout drew. */
133
+ function frame(f) {
134
+ const { prefix, v, options, theme, road, ctx } = f;
135
+ const picture = resolveBackground(options.background, theme, f.width, f.height, prefix);
136
+ const defs = markDefs(ctx) + (picture?.defs ?? "");
137
+ const scope = scopeOf(options, prefix);
138
+ const style = theme === "none" || options.style === false ? "" : `<style>${styleFor(`.${scope}`, theme, { road, links: f.linkCount > 0, follow: f.followed, bgAuto: picture?.auto, layout: f.kind !== "tree" || options.headers === true }, picture?.inks)}</style>`;
139
+ const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(f.width)}" height="${num(f.height)}"${theme === "none" ? ' fill="none"' : ""}/>${picture?.body ?? ""}`;
77
140
  return [
78
- `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen ${prefix}${road ? " tb-look-road" : ""}${path.size > 0 ? " tb-follow" : ""}${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">`,
79
- `<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
141
+ `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen ${prefix}${scope !== prefix ? ` ${scope}` : ""}${road ? " tb-look-road" : ""}${f.followed ? " tb-follow" : ""}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(f.width)} ${num(f.height)}" width="${num(f.width)}" height="${num(f.height)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${prefix}-t ${prefix}-d">`,
142
+ `<title id="${prefix}-t">${esc(f.title)}</title><desc id="${prefix}-d">${esc(f.desc)}</desc>`,
80
143
  style,
81
144
  defs ? `<defs>${defs}</defs>` : "",
82
145
  bg,
83
- ...plan.links,
84
- ...drawn,
85
- ...plan.texts,
86
- ...plan.extras,
146
+ ...f.links,
147
+ ...f.parts,
87
148
  "</svg>",
88
149
  ].filter(Boolean).join("\n");
89
150
  }
151
+ /** The class that carries a drawing's rules: its `styleScope` when it has one, else its own id prefix. */
152
+ function scopeOf(o, prefix) {
153
+ if (o.styleScope === undefined)
154
+ return prefix;
155
+ if (typeof o.styleScope !== "string" || !/^[A-Za-z_][\w-]*$/.test(o.styleScope))
156
+ throw new RangeError("taisen-brackets: styleScope is one word of letters, digits, - and _");
157
+ return o.styleScope;
158
+ }
159
+ /**
160
+ * The stylesheet of a drawing with these options, for a page that draws the same bracket twice (a desk's size and a phone's): put it in the page once, give each drawing the same `styleScope`
161
+ * and `style: false`, and every drawing takes its colours from it. Needs `styleScope`; `theme`, `look`, `background`, `layout`, `headers`, and whether there is an `href` or a `highlightPath` decide which rules are in it.
162
+ */
163
+ export function bracketStyle(options) {
164
+ if (options.styleScope === undefined)
165
+ throw new RangeError("taisen-brackets: bracketStyle needs a styleScope");
166
+ const scope = scopeOf(options, "");
167
+ const theme = options.theme ?? "auto";
168
+ if (theme === "none")
169
+ return "";
170
+ const picture = resolveBackground(options.background, theme, 100, 100, "x");
171
+ return styleFor(`.${scope}`, theme, { road: options.look === "road", links: typeof options.href === "function", follow: options.highlightPath !== undefined, bgAuto: picture?.auto, layout: (options.layout ?? "tree") !== "tree" || options.headers === true }, picture?.inks);
172
+ }
173
+ /** The groups' names with the east first, for the series card: the names the teams carry, ordered so that a name with "east" comes before one with "west". */
174
+ function eastFirst(input) {
175
+ const teams = Array.isArray(input) ? [] : input.teams ?? [];
176
+ const names = [...new Set(teams.map((t) => t.group).filter((g) => typeof g === "string"))];
177
+ if (names.length < 2)
178
+ return undefined;
179
+ const rank = (g) => (/east/i.test(g) ? 0 : /west/i.test(g) ? 2 : 1);
180
+ return names.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1));
181
+ }
90
182
  // ---------------------------------------------------------------------------------------------- the plan
91
- function planBracket(layout, o, v, size, vertical, ctx, road, path) {
183
+ function planBracket(layout, o, v, size, vertical, ctx, road, path, kind) {
184
+ // The layouts that draw each tie their own way take the tree's places and lines, and fill the boxes themselves.
185
+ const V = VARIANTS.includes(kind) ? kind : null;
92
186
  const pad = vertical ? 14 : 22;
93
187
  const showLabels = o.roundLabels ?? true;
94
188
  const poster = o.theme === "poster";
@@ -96,9 +190,15 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
96
190
  const links = [];
97
191
  const texts = [];
98
192
  const extras = [];
193
+ // The connector lines as they are drawn, so that the names of the rounds can be placed clear of them.
194
+ const segs = [];
195
+ const addLine = (...pts) => pts.slice(1).forEach(([x, y], i) => segs.push({ x0: pts[i][0], y0: pts[i][1], x1: x, y1: y }));
196
+ const clear = (lo, hi) => (w, pref, y) => freeX(segs, y - 16, y + 7, w, pref, lo, hi);
197
+ const finish = (plan) => ({ ...plan, texts: plan.texts.map((t) => (typeof t === "function" ? t() : t)) });
99
198
  const W0 = size.width;
100
199
  const titleH = (o.title ? (poster ? 46 : 38) : 0) + (o.subtitle ? 24 : 0) + (o.title || o.subtitle ? 10 : 0);
101
200
  const hasGroups = layout.sides.some((s) => s.label);
201
+ const groupRoom = o.headers === true ? 34 : 28;
102
202
  const champ = (o.champion ?? true) && layout.champion !== null && layout.finalIndex !== null;
103
203
  const withThird = layout.third !== null && (o.thirdPlace ?? "below") !== "hide";
104
204
  // 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.
@@ -121,6 +221,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
121
221
  let others = [];
122
222
  let ow = 0;
123
223
  let extraH0 = 0;
224
+ let labelRoom = showLabels ? 24 : 6;
124
225
  if (!vertical) {
125
226
  const availW = W0 - 2 * pad;
126
227
  const g = 0.2;
@@ -131,7 +232,10 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
131
232
  D = clamp(d, 40, 220);
132
233
  gapT = D * g;
133
234
  centreT = Math.max(centreMin, D * 1.2);
134
- const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
235
+ // The names of the rounds head their columns: a name that cannot be one line in its column is set on two, which needs a taller strip over the boxes.
236
+ if (showLabels && layout.columns.some((c) => !nameFits(v, road, roundLabel(layout, c, o.roundNames, v), D + gapT * 0.8)))
237
+ labelRoom = 34;
238
+ const top = pad + titleH + (hasGroups ? groupRoom : 0) + labelRoom;
135
239
  others = othersOf(layout);
136
240
  ow = clamp(D * 1.1, 120, 200);
137
241
  extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
@@ -151,8 +255,13 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
151
255
  boxW = S;
152
256
  }
153
257
  // Full boxes (names) only where the longest name fits beside its mark and a score.
154
- const longest = Math.max(0, ...layout.teams.map((t) => textWidth(t.name, 13)));
155
- const longestAbbr = Math.max(0, ...layout.teams.map((t) => textWidth(abbrOf(t, v), 13)));
258
+ const wide = boldWidth;
259
+ const longest = Math.max(0, ...layout.teams.map((t) => wide(t.name, 13)));
260
+ const longestAbbr = Math.max(0, ...layout.teams.map((t) => wide(abbrOf(t, v), 13)));
261
+ // Seed tags (the road look with `seeds`): a small pill beside each mark, or above it in a box of marks alone.
262
+ const seedsDefault = V === "rows" || V === "update" || V === "series" || V === "columns";
263
+ const tagsOn = (road || (V !== null && V !== "columns")) && (o.seeds ?? seedsDefault) && layout.ties.some((t) => tagOf(t, 0) !== "" || tagOf(t, 1) !== "");
264
+ const tagW = tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 8))) + 8 : 0;
156
265
  // The road look's marks and scores are large: they are scaled down together until a row fits its box, before a name is dropped for its abbreviation.
157
266
  const anyNotes = layout.ties.some((t) => noteParts(t, v).length > 0);
158
267
  // The road look's boxes of marks alone carry each score under its mark, unless marks alone were asked for.
@@ -162,9 +271,9 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
162
271
  const { font, md, score } = metrics(rowH, road);
163
272
  const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
164
273
  const anyPens = layout.ties.some((t) => t.result.pens !== null);
165
- const extra = (anyBye ? textWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? textWidth("(00)", Math.max(score * 0.55, 8)) + 3 : 0);
166
- const fixed = clamp(rowH * 0.16, 5, 12) + clamp(rowH * 0.18, 5, 10) + 8 + 10;
167
- const varying = md + (named === "full" ? longest : longestAbbr) * (font / 13) + textWidth("00", score) + extra;
274
+ const extra = (anyBye ? wide(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? wide("(00)", Math.max(score * 0.55, 8)) + 5 : 0);
275
+ const fixed = clamp(rowH * 0.16, 5, 12) + clamp(rowH * 0.18, 5, 10) + 8 + 10 + (tagW > 0 ? tagW + 3 : 0);
276
+ const varying = md + (named === "full" ? longest : longestAbbr) * (font / 13) + wide("00", score) + extra;
168
277
  return Math.min(1, (w - fixed) / varying);
169
278
  };
170
279
  let fitNow = 1;
@@ -174,14 +283,17 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
174
283
  // Room for the score, and for the words some boxes carry beside it: a bye, a shoot-out's score.
175
284
  const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
176
285
  const anyPens = layout.ties.some((t) => t.result.pens !== null);
177
- const extra = (anyBye ? textWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? textWidth("(00)", Math.max(font - 2, 8)) + 3 : 0);
178
- const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", score) + extra + 14;
286
+ const extra = (anyBye ? boldWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? boldWidth("(00)", Math.max(font - 2, 8)) + 3 : 0);
287
+ const needed = 6 + md + 5 + longest * (font / 13) + 8 + boldWidth("00", score) + extra + 14;
179
288
  const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
180
289
  return named === "mark" && o.markShape === "none" ? "compact" : named;
181
290
  };
182
291
  // The road look: a box is as high as its width lets the large marks and scores be. A name is kept (full) only where that leaves rows of 28 or more;
183
292
  // else the abbreviation (compact) where rows of 18 or more fit; else only the marks.
184
- const roadPick = (w, hMax) => {
293
+ const codesOn = road && (o.codes ?? false);
294
+ const markCols = road && (markScores || tagsOn || codesOn || o.vs === true);
295
+ const anyPensAll = layout.ties.some((t) => t.result.pens !== null);
296
+ const roadPick = (w, hMax, grow = false) => {
185
297
  const search = (named, hMin) => {
186
298
  let h = hMax;
187
299
  while (h > hMin && fitOf(w, h, named) < 0.94)
@@ -198,15 +310,38 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
198
310
  const compact = search("compact", 36);
199
311
  chosen = full.ok ? full : compact.ok ? compact : o.markShape === "none" ? compact : null;
200
312
  }
201
- const m = Math.min((w - 12) / 2 - 2, 56);
202
- chosen ??= { detail: o.markShape === "none" ? "compact" : "mark", h: Math.min(hMax, markScores ? m + clamp(m * 0.5, 10, 24) * 1.3 + 14 : 2 * Math.min(w - 8, 60) + 8) };
313
+ // A box of marks alone is as high as its marks, tags, codes and scores need; on a tall page it may be higher than the usual box, in a row it may not.
314
+ const m = Math.min((w - 12) / 2 - 3, 56);
315
+ const sf = clamp(m * 0.5, 9, 24);
316
+ const need = 10 + m + 3 + (markScores ? sf * 1.15 + (anyPensAll && m < 40 ? sf * 0.7 : 0) : 0) + (codesOn ? clamp(m * 0.24, 7, 11) * 1.25 : 0) + (tagsOn ? 12 : 0);
317
+ chosen ??= { detail: o.markShape === "none" ? "compact" : "mark", h: markCols ? Math.min(grow ? Math.max(hMax, need) : hMax, need) : Math.min(hMax, 2 * Math.min(w - 8, 60) + 8) };
203
318
  if (chosen.detail !== "mark")
204
319
  fitNow = Math.min(1, fitOf(w, chosen.h, chosen.detail));
205
320
  return chosen;
206
321
  };
322
+ // The layouts of their own: how much a box says follows from its width and height; a tall page gives each a height of its own.
323
+ const variantDetail = (w) => {
324
+ if (o.detail && o.detail !== "auto")
325
+ return o.detail;
326
+ if (V === "shields")
327
+ return "mark";
328
+ if (V === "update" || V === "series")
329
+ return w >= 56 ? "compact" : "mark";
330
+ // The box's own painter leaves out what does not fit (the tag, then the names' length); the names are asked for where there is room for most of them.
331
+ if (V === "columns")
332
+ return w >= 110 ? "full" : w >= 60 ? "compact" : "mark";
333
+ return w >= 150 ? "full" : w >= 84 ? "compact" : "mark";
334
+ };
335
+ const oneLine = V === "series" && vertical && layout.ties.every((t) => seriesLineWidth(t, v, 10, o.scores ?? true) <= boxW - 8);
336
+ const variantH = V === "rows" ? 54 : V === "shields" ? 76 : V === "update" ? 46 : V === "columns" ? 84 : oneLine ? 32 : 46;
207
337
  let detail;
208
- if (road) {
209
- const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH);
338
+ if (V !== null) {
339
+ detail = variantDetail(boxW);
340
+ if (vertical)
341
+ boxH = variantH;
342
+ }
343
+ else if (road) {
344
+ const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH, vertical);
210
345
  detail = picked.detail;
211
346
  if (vertical)
212
347
  boxH = picked.h;
@@ -220,9 +355,10 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
220
355
  if (vertical)
221
356
  boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
222
357
  }
358
+ // The names of the rounds stand on their own strip between the rounds, under the lines that join them: 6 for the bus, 20 for the pill, and room round both.
223
359
  if (vertical) {
224
360
  D = boxH;
225
- gapT = showLabels ? 26 : 16;
361
+ gapT = showLabels ? 32 : 16;
226
362
  }
227
363
  // The champion's block: a trophy, a large mark, the name and the word; and the third-place match below the final.
228
364
  const champMark = vertical ? (road ? clamp(size.width * 0.14, 54, 96) : 54) : clamp(centreT * (road ? 0.4 : 0.34), 44, road ? 96 : 84);
@@ -231,7 +367,53 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
231
367
  const champH = champ ? trophyH + (hasTrophy ? 6 : 0) + champMark + 8 + nameFont + 6 + 11 + 2 : 0;
232
368
  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;
233
369
  let finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
370
+ // The final of the columns layout is two tall slots side by side.
371
+ if (V === "columns")
372
+ finalH = vertical ? Math.max(boxH, 96) : Math.min(Math.max(boxH * 1.7, 120), 220);
234
373
  const thirdH = withThird && !bottomThird ? 18 + boxH + 14 : 0;
374
+ // A final alone (the World Cups of 1974 and 1978 ended with one and a third-place match): there is no tree to join, so it is centred, the champion over it and the third place under it.
375
+ const lone = !halves && layout.sides.length === 1 && layout.sides[0].boxes.length === 1 && layout.finalIndex !== null && layout.sides[0].boxes[0].i === layout.finalIndex;
376
+ if (lone) {
377
+ const availW = W0 - 2 * pad;
378
+ const wl = vertical ? Math.min(availW, road ? 340 : 300) : clamp(availW * 0.3, 240, 380);
379
+ let hl = V === "columns" ? 120 : V === "shields" ? 86 : V !== null ? 72 : road ? 0 : 92;
380
+ let dl;
381
+ if (V !== null)
382
+ dl = variantDetail(wl);
383
+ else if (road) {
384
+ const picked = roadPick(wl, 132);
385
+ dl = picked.detail;
386
+ hl = picked.h;
387
+ }
388
+ else
389
+ dl = pick(wl, hl);
390
+ const strays = othersOf(layout);
391
+ const ows = Math.min(availW, 200);
392
+ const strayH = othersHeight(strays.length, W0, pad, ows, OTHER_H);
393
+ const labelH = showLabels ? 22 : 8;
394
+ const content = (champ ? champH + 14 : 0) + labelH + hl + (withThird ? 8 + 18 + hl + 8 : 0) + strayH;
395
+ const top0 = pad + titleH;
396
+ const height = Math.max(size.height ?? 0, top0 + content + pad);
397
+ const cx = W0 / 2;
398
+ let y = top0 + Math.max(6, (height - top0 - pad - content) / 2);
399
+ if (champ) {
400
+ extras.push(...championBlock(layout.champion, cx, y, champMark, trophyH, nameFont, o, v, ctx, poster));
401
+ y += champH + 14;
402
+ }
403
+ if (showLabels)
404
+ texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), cx, y + 12, "middle"));
405
+ y += labelH;
406
+ boxes.push({ rect: { x: cx - wl / 2, y, w: wl, h: hl }, tie: layout.ties[layout.finalIndex], order: layout.sides[0].boxes[0].order, bye: null, byes: [], final: true, champion: champ });
407
+ y += hl;
408
+ if (withThird) {
409
+ thirdAtFoot(layout, y + 8, cx, wl, hl, v, road, boxes, texts);
410
+ y += 8 + 18 + hl + 8;
411
+ }
412
+ if (strays.length > 0)
413
+ othersBlock(layout, strays, y + 6, W0, pad, ows, OTHER_H, v, road, boxes, texts);
414
+ texts.push(...headings(v, o, W0, pad, poster));
415
+ return finish({ width: W0, height, boxes, links: [], texts, extras, detail: dl, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn, variant: V, em: { full: longest / 13, abbr: longestAbbr / 13, tag: tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 1))) : 0 } });
416
+ }
235
417
  // ----- the slot positions along t
236
418
  if (!vertical) {
237
419
  const sizes = [];
@@ -243,7 +425,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
243
425
  sizes.forEach((sz) => { starts.push(t); t += sz + gapT; });
244
426
  const width = Math.max(W0, total + 2 * pad);
245
427
  // ----- span positions
246
- const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
428
+ const top = pad + titleH + (hasGroups ? groupRoom : 0) + labelRoom;
247
429
  const treeH = rows * rowStep;
248
430
  // The final sits at the middle of the rows; the champion above it and the third place below need room.
249
431
  const upNeed = (champ ? champH + 8 : 0) + 22 + finalH / 2;
@@ -294,6 +476,8 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
294
476
  extras.push(...championBlock(layout.champion, cx, fy - champH / 2, champMark, trophyH, nameFont, o, v, ctx, poster));
295
477
  }
296
478
  }
479
+ if (V === "columns")
480
+ growColumns(layout, rects, false, S, treeH, top, height - pad - extraH0);
297
481
  if (showLabels) {
298
482
  for (const c of layout.columns) {
299
483
  const name = roundLabel(layout, c, o.roundNames, v);
@@ -310,13 +494,17 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
310
494
  return;
311
495
  const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
312
496
  const x0 = Math.min(...ks.map((k) => starts[k])), x1 = Math.max(...ks.map((k) => starts[k] + D));
313
- 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>`);
497
+ const labelY = top - (showLabels ? labelRoom + 12 : 14);
498
+ if (o.headers === true)
499
+ texts.push(headTile(poster ? v.upper(sd.label) : sd.label, (x0 + x1) / 2, x1 - x0, labelY, sd.key === "l" ? 0 : 1, layout.sides.length === 2));
500
+ else
501
+ texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(labelY)}" font-size="${poster ? 16 : 15}" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(poster ? v.upper(sd.label) : sd.label)}</text>`);
314
502
  });
315
503
  }
316
504
  // lines
317
505
  for (const t of layout.ties) {
318
506
  const p = layout.parent[t.index];
319
- if (p === -1)
507
+ if (p === -1 || V === "shields")
320
508
  continue;
321
509
  const a = rects.get(t.index), b = rects.get(p);
322
510
  if (!a || !b || layout.place[t.index] === "o")
@@ -325,16 +513,17 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
325
513
  const x0 = goingRight ? a.x + a.w : a.x, x1 = goingRight ? b.x : b.x + b.w;
326
514
  const y0 = a.y + a.h / 2, y1 = b.y + b.h / 2;
327
515
  const xm = (x0 + x1) / 2;
516
+ addLine([x0, y0], [xm, y0], [xm, y1], [x1, y1]);
328
517
  links.push(`<path class="${lineClass(path, t.index, p)}" d="M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1)}"${lineAttrs(o)}/>`);
329
518
  }
330
519
  if (bottomThird)
331
520
  thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, road, boxes, texts);
332
521
  othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, road, boxes, texts);
333
522
  texts.push(...headings(v, o, width, pad, poster));
334
- return { width, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores };
523
+ return finish({ width, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn, variant: V, em: { full: longest / 13, abbr: longestAbbr / 13, tag: tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 1))) : 0 } });
335
524
  }
336
525
  // ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
337
- const groupBand = hasGroups ? 26 : 0;
526
+ const groupBand = hasGroups ? (o.headers === true ? 34 : 26) : 0;
338
527
  // 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.
339
528
  const champTop = halves && champ ? champH + 14 : 0;
340
529
  const topT = pad + titleH + champTop + groupBand + (showLabels ? 14 : 0);
@@ -345,14 +534,14 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
345
534
  const othersV = othersHeight(others.length, W0, pad, ow, OTHER_H);
346
535
  // The height the page needs for boxes `h` high with `gap` between the rounds, and what it keeps for the final's band and the third-place match.
347
536
  const needOf = (h, gap) => {
348
- const finalHere = detail === "mark" ? Math.max(h, 52) : h;
537
+ const finalHere = V === "columns" ? Math.max(h, finalH) : detail === "mark" ? Math.max(h, 52) : h;
349
538
  const centre = (showLabels ? 32 : 0) + 6 + finalHere + 14;
350
539
  const thirdV = bottomThird ? 18 + (road ? h : 52) + 16 : 0;
351
540
  const band = h + gap;
352
541
  return (halves ? topT + bandsPerSide * band + centre + bandsPerSide * band + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * band + (champ ? champH : 0) + pad) + othersV + thirdV;
353
542
  };
354
543
  // The road look fits a tall page: the boxes are made lower until the rounds fit, or, with room to spare, the room left over is shared between the rounds.
355
- if (road && size.height !== null) {
544
+ if (road && size.height !== null && V === null) {
356
545
  while (boxH > 40 && needOf(boxH, gapT) > size.height)
357
546
  boxH -= 2;
358
547
  const slack = size.height - needOf(boxH, gapT);
@@ -361,6 +550,21 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
361
550
  D = boxH;
362
551
  finalH = detail === "mark" ? Math.max(boxH, 52) : boxH;
363
552
  }
553
+ // The layouts of their own fill a tall page too: the boxes are made taller, and the rounds are spaced apart, until the page is as high as it was asked to be.
554
+ if (V !== null && size.height !== null && vertical) {
555
+ const cap = V === "series" ? (oneLine ? 52 : 60) : V === "columns" ? 150 : V === "shields" ? 110 : V === "rows" ? 96 : 80;
556
+ while (boxH < cap && needOf(boxH + 2, gapT) <= size.height) {
557
+ boxH += 2;
558
+ if (V !== "columns")
559
+ finalH = boxH;
560
+ }
561
+ D = boxH;
562
+ const slack = size.height - needOf(boxH, gapT);
563
+ if (slack > 0 && bandCount > 0)
564
+ gapT += Math.min(slack / bandCount, 56);
565
+ if (V !== "columns")
566
+ finalH = boxH;
567
+ }
364
568
  const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
365
569
  const thirdH2 = road ? boxH : 52;
366
570
  const thirdBlockV = bottomThird ? 18 + thirdH2 + 16 : 0;
@@ -394,7 +598,8 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
394
598
  if (champ)
395
599
  extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
396
600
  if (showLabels) {
397
- texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, y + 12, "middle"));
601
+ const at = y + 12;
602
+ texts.push(() => label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, at, "middle", 1e9, clear(pad - 4, W0 - pad + 4)));
398
603
  y += 16;
399
604
  }
400
605
  const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
@@ -405,12 +610,30 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
405
610
  const fr = rects.get(layout.finalIndex);
406
611
  extras.push(...championBlock(layout.champion, fr.x + fr.w / 2, fr.y + fr.h + 12, champMark, trophyH, nameFont, o, v, ctx, poster));
407
612
  }
613
+ if (V === "columns")
614
+ growColumns(layout, rects, true, S, treeW, pad, W0 - pad);
408
615
  if (showLabels) {
409
616
  layout.columns.forEach((c, ci) => {
410
617
  const name = roundLabel(layout, c, o.roundNames, v);
411
618
  const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
412
- for (const sd of sides)
413
- texts.push(road ? label(v, road, name, pad, bandStart(sd, ci) - 8, "start") : `<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>`);
619
+ for (const sd of sides) {
620
+ const at = bandStart(sd, ci) - 8;
621
+ texts.push(() => {
622
+ if (road)
623
+ return label(v, road, name, pad, at, "start", 1e9, clear(pad - 4, W0 - pad + 4));
624
+ const shown = v.upper(name);
625
+ let size = 11, left = pad - 4;
626
+ for (const s of [11, 9.5, 8.5]) {
627
+ size = s;
628
+ const found = freeX(segs, at - 16, at + 7, boldWidth(shown, s) * 1.06 + 8, pad - 4, pad - 4, W0 - pad + 4);
629
+ if (found !== null) {
630
+ left = found;
631
+ break;
632
+ }
633
+ }
634
+ return `<text class="tb-round tb-halo" x="${num(left + 4)}" y="${num(at)}" font-size="${size}" font-weight="600" letter-spacing=".08em">${esc(shown)}</text>`;
635
+ });
636
+ }
414
637
  });
415
638
  }
416
639
  if (hasGroups) {
@@ -418,12 +641,15 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
418
641
  if (!sd.label)
419
642
  return;
420
643
  const y = sd.key === "l" ? pad + titleH + champTop + 16 : totalT - pad - 2;
421
- 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>`);
644
+ if (o.headers === true)
645
+ texts.push(headTile(v.upper(sd.label), midX, W0 - 2 * pad, y, sd.key === "l" ? 0 : 1, layout.sides.length === 2));
646
+ else
647
+ 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>`);
422
648
  });
423
649
  }
424
650
  for (const t of layout.ties) {
425
651
  const p = layout.parent[t.index];
426
- if (p === -1)
652
+ if (p === -1 || V === "shields")
427
653
  continue;
428
654
  const a = rects.get(t.index), b = rects.get(p);
429
655
  if (!a || !b || layout.place[t.index] === "o")
@@ -431,14 +657,48 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
431
657
  const down = b.y > a.y;
432
658
  const y0 = down ? a.y + a.h : a.y, y1 = down ? b.y : b.y + b.h;
433
659
  const x0c = a.x + a.w / 2, x1c = b.x + b.w / 2;
434
- const ym = down ? y0 + gapT * 0.4 : y0 - gapT * 0.4;
660
+ // With the rounds named, the line turns just outside the box it leaves (going down) or just outside the box it enters (going up), so that the strip in front of the later round is free for its name.
661
+ const ym = showLabels ? (down ? y0 + 6 : y1 + 6) : down ? y0 + gapT * 0.4 : y0 - gapT * 0.4;
662
+ addLine([x0c, y0], [x0c, ym], [x1c, ym], [x1c, y1]);
435
663
  links.push(`<path class="${lineClass(path, t.index, p)}" d="M${num(x0c)} ${num(y0)}V${num(ym)}H${num(x1c)}V${num(y1)}"${lineAttrs(o)}/>`);
436
664
  }
437
665
  if (bottomThird)
438
666
  thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, road ? Math.max(boxW, 200) : 200), thirdH2, v, road, boxes, texts);
439
667
  othersBlock(layout, others, totalT - pad + thirdBlockV + 6, W0, pad, ow, OTHER_H, v, road, boxes, texts);
440
668
  texts.push(...headings(v, o, W0, pad, poster));
441
- return { width: W0, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores };
669
+ return finish({ width: W0, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn, variant: V, em: { full: longest / 13, abbr: longestAbbr / 13, tag: tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 1))) : 0 } });
670
+ }
671
+ /**
672
+ * The columns layout's tall slots: a tie of a later round is made taller than the first round's, as tall as the room between its neighbours of the same column leaves (`base` times 1.55 for each round in, at most),
673
+ * on the same centre. `span` is the room across the tree, for a column with a single tie.
674
+ */
675
+ function growColumns(layout, rects, vertical, base, span, lo, hi) {
676
+ const centre = (r) => (vertical ? r.x + r.w / 2 : r.y + r.h / 2);
677
+ for (const sd of layout.sides) {
678
+ layout.columns.forEach((col, ci) => {
679
+ const here = sd.boxes.filter((b) => b.round === col).map((b) => rects.get(b.i)).filter((r) => r !== undefined);
680
+ if (here.length === 0)
681
+ return;
682
+ const at = here.map(centre).sort((a, b) => a - b);
683
+ let gap = Infinity;
684
+ for (let i = 1; i < at.length; i++)
685
+ gap = Math.min(gap, at[i] - at[i - 1]);
686
+ const want = Math.max(base, Math.min(base * 1.55 ** ci, gap === Infinity ? span * 0.6 : gap - 8));
687
+ for (const r of here) {
688
+ const c = centre(r);
689
+ // Never past the room there is on either side of the centre.
690
+ const size = Math.max(Math.min(base, 2 * (c - lo), 2 * (hi - c)), Math.min(want, 2 * (c - lo), 2 * (hi - c)));
691
+ if (vertical) {
692
+ r.x = c - size / 2;
693
+ r.w = size;
694
+ }
695
+ else {
696
+ r.y = c - size / 2;
697
+ r.h = size;
698
+ }
699
+ }
700
+ });
701
+ }
442
702
  }
443
703
  /** A line's class: `tb-path` when both ties it joins are on the highlighted team's path. */
444
704
  function lineClass(path, from, to) {
@@ -453,18 +713,108 @@ const centreMinLtr = (d) => d * 1.1;
453
713
  function lineAttrs(o) {
454
714
  return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
455
715
  }
456
- function label(v, road, text, x, y, anchor, maxWidth = 1e9) {
457
- if (!road)
458
- 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>`;
459
- // The road look names a round on a pill, set a little smaller (to 9) before the name is cut.
716
+ /** The two lines a name is best broken into at a space (the widest line as narrow as it can be), or `null` for a name of one word. */
717
+ function twoLines(text, size, measure) {
718
+ const words = text.split(" ");
719
+ let best = null;
720
+ for (let i = 1; i < words.length; i += 1) {
721
+ const pair = [words.slice(0, i).join(" "), words.slice(i).join(" ")];
722
+ if (best === null || Math.max(measure(pair[0], size), measure(pair[1], size)) < Math.max(measure(best[0], size), measure(best[1], size)))
723
+ best = pair;
724
+ }
725
+ return best;
726
+ }
727
+ /** The bold, letter-spaced width of a round's name (as a pill or a heading sets it). */
728
+ const nameWidth = (t, size) => boldWidth(t, size) * 1.06;
729
+ /**
730
+ * How a round's name is set within `maxWidth` (the column it heads): on one line at 11, smaller down to 9 (the pill's own room is 9 either side, or 4 where that is all there is), or, where it still does not
731
+ * fit, on two lines at 9, 8.5 and 8: a name is never cut while a smaller or a second line will hold it. The road look's pill and the classic look's heading take the same rule.
732
+ */
733
+ function nameFits(v, road, text, maxWidth) {
734
+ const upper = v.upper(text);
735
+ return nameWidth(upper, 9) + (road ? 8 : 0) <= maxWidth;
736
+ }
737
+ function label(v, road, text, x, y, anchor, maxWidth = 1e9, place) {
460
738
  const upper = v.upper(text);
739
+ if (!road) {
740
+ // A column's heading: 11 down to 9.5 on one line, then two lines at 9.5, 9 and 8.5; the last resort cuts it.
741
+ if (maxWidth < 1e9 && nameWidth(upper, 11) > maxWidth) {
742
+ let size = 11;
743
+ while (size > 9.5 && nameWidth(upper, size) > maxWidth)
744
+ size -= 0.5;
745
+ if (nameWidth(upper, size) <= maxWidth)
746
+ return `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="${size}" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(upper)}</text>`;
747
+ for (const s of [9.5, 9, 8.5]) {
748
+ const pair = twoLines(upper, s, nameWidth);
749
+ if (pair && Math.max(nameWidth(pair[0], s), nameWidth(pair[1], s)) <= maxWidth)
750
+ return pair.map((line, i) => `<text class="tb-round" x="${num(x)}" y="${num(y - (1 - i) * 10)}" font-size="${s}" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(line)}</text>`).join("");
751
+ }
752
+ return `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="8.5" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(fit(upper, 8.5, maxWidth, nameWidth))}</text>`;
753
+ }
754
+ const shown = upper;
755
+ const plain = `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="11" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(shown)}</text>`;
756
+ if (!place)
757
+ return plain;
758
+ // The name is kept clear of the connector lines: where it cannot stand where it was put, it moves along its row.
759
+ for (const size of [11, 9.5, 8.5]) {
760
+ const w = nameWidth(shown, size) + 8;
761
+ const pref = anchor === "middle" ? x - w / 2 : x - 4;
762
+ const left = place(w, pref, y);
763
+ if (left === null)
764
+ continue;
765
+ return left === pref && size === 11 ? plain : `<text class="tb-round" x="${num(left + 4)}" y="${num(y)}" font-size="${size}" font-weight="600" letter-spacing=".08em" text-anchor="start">${esc(shown)}</text>`;
766
+ }
767
+ return plain;
768
+ }
769
+ // The road look names a round on a pill. Down to 9 on one line (with 9 either side), then a snugger pill (4 either side), then two lines at 9, 8.5 and 8.
461
770
  let s = 11;
462
- while (s > 9 && textWidth(upper, s * 1.18) > maxWidth - 18)
771
+ while (s > 9 && nameWidth(upper, s) > maxWidth - 18)
463
772
  s -= 0.5;
464
- const shown = fit(upper, s * 1.18, maxWidth - 18);
465
- const w = textWidth(shown, s * 1.18) + 18;
466
- const x0 = anchor === "middle" ? x - w / 2 : x;
467
- return `<rect class="tb-panel" x="${num(x0)}" y="${num(y - 14.5)}" width="${num(w)}" height="20" rx="10"/><text class="tb-lab" x="${num(x0 + w / 2)}" y="${num(y)}" font-size="${num(s)}" font-weight="700" letter-spacing=".08em" text-anchor="middle">${esc(shown)}</text>`;
773
+ let rows = [upper];
774
+ let side = 9;
775
+ if (nameWidth(upper, s) > maxWidth - 18) {
776
+ if (nameWidth(upper, s) <= maxWidth - 8)
777
+ side = 4;
778
+ else {
779
+ rows = [fit(upper, s, maxWidth - 12, nameWidth)];
780
+ for (const size of [9, 8.5, 8]) {
781
+ const pair = twoLines(upper, size, nameWidth);
782
+ if (pair && Math.max(nameWidth(pair[0], size), nameWidth(pair[1], size)) <= maxWidth - 12) {
783
+ rows = pair;
784
+ s = size;
785
+ side = 6;
786
+ break;
787
+ }
788
+ if (pair && size === 8) {
789
+ rows = pair.map((line) => fit(line, 8, maxWidth - 12, nameWidth));
790
+ s = 8;
791
+ side = 6;
792
+ }
793
+ }
794
+ }
795
+ }
796
+ const widest = Math.max(...rows.map((r) => nameWidth(r, s)));
797
+ let w = widest + 2 * side;
798
+ let x0 = anchor === "middle" ? x - w / 2 : x;
799
+ let fs = s;
800
+ if (place && rows.length === 1) {
801
+ // Where the lines leave no room, the name is set smaller (to 8) and the pill made snugger (side 6, then 4).
802
+ search: for (const size of s > 8 ? [s, 8.5, 8].filter((z) => z <= s) : [s]) {
803
+ for (const sd of [9, 6, 4]) {
804
+ w = nameWidth(rows[0], size) + 2 * sd;
805
+ const pref = anchor === "middle" ? x - w / 2 : x;
806
+ const found = place(w, pref, y);
807
+ x0 = found ?? pref;
808
+ fs = size;
809
+ if (found !== null)
810
+ break search;
811
+ }
812
+ }
813
+ }
814
+ const put = (line, by) => `<text class="tb-lab" x="${num(x0 + w / 2)}" y="${num(by)}" font-size="${num(fs)}" font-weight="700" letter-spacing=".08em" text-anchor="middle">${esc(line)}</text>`;
815
+ if (rows.length === 2)
816
+ return `<rect class="tb-panel" x="${num(x0)}" y="${num(y - 23.5)}" width="${num(w)}" height="29" rx="12"/>${put(rows[0], y - 12)}${put(rows[1], y - 1.5)}`;
817
+ return `<rect class="tb-panel" x="${num(x0)}" y="${num(y - 14.5)}" width="${num(w)}" height="20" rx="10"/>${put(rows[0], y)}`;
468
818
  }
469
819
  function headings(v, o, width, pad, poster) {
470
820
  const out = [];
@@ -505,7 +855,7 @@ function championBlock(team, cx, top, mark, trophyH, nameFont, o, v, ctx, poster
505
855
  }
506
856
  /** 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. */
507
857
  function othersOf(layout) {
508
- return [...layout.other, ...layout.robin];
858
+ return [...layout.other, ...layout.robin, ...layout.playIn];
509
859
  }
510
860
  function othersHeight(count, width, pad, ow, oh) {
511
861
  if (!count)
@@ -536,7 +886,7 @@ function hrefOf(o, tie, side) {
536
886
  const url = side === undefined ? o.href(tie) : o.href(tie, side);
537
887
  return typeof url === "string" && safeHref(url) ? url.trim() : null;
538
888
  }
539
- function drawBox(p, plan, o, v, ctx, linked) {
889
+ function drawBox(p, plan, o, v, ctx, linked, side) {
540
890
  const { rect, tie } = p;
541
891
  const { x, y, w, h } = rect;
542
892
  const detail = plan.detail;
@@ -568,25 +918,76 @@ function drawBox(p, plan, o, v, ctx, linked) {
568
918
  return inner;
569
919
  return anchor(url, `${team.name}: ${description}`, `<rect class="tb-hit" x="${num(rx)}" y="${num(ry)}" width="${num(rw)}" height="${num(rh)}" fill="transparent"/>${inner}`);
570
920
  };
571
- if (detail === "mark" && plan.markScores) {
572
- // Two columns: each team's mark with its score under it.
573
- const colW = (w - 12) / 2;
574
- const m = Math.min(colW - 2, 56, h - 14);
575
- const sf = clamp(m * 0.5, 10, 24);
576
- const withScore = scoresOn && r.score !== null;
577
- const markH = withScore ? m : h - 12;
578
- const top = y + (h - (m + (withScore ? sf * 1.3 : 0))) / 2;
921
+ if (plan.variant !== null) {
922
+ const env = {
923
+ kind: plan.variant, x, y, w, h, tie, order: p.order, side, final: p.final, road, detail, vertical: plan.vertical, tags: plan.tags, codes: plan.codes, scoresOn, v, ctx, o,
924
+ cls, link: row, tagOf, em: plan.em,
925
+ };
926
+ parts.push(...paintVariant(env));
927
+ }
928
+ else if (detail === "mark" && plan.markCols) {
929
+ // Two columns, each a stack: the seed tag, the mark, its three letters, the score. The marks are as large as the box lets the whole stack be, and every part is inside the box.
930
+ const padX = 6, padY = 5;
931
+ const colW = (w - 2 * padX) / 2;
932
+ const withScore = plan.markScores && scoresOn && r.score !== null;
933
+ const tags = [0, 1].map((k) => (plan.tags ? tagOf(tie, k) : ""));
934
+ const tagH = tags.some((t) => t !== "") ? 12 : 0;
935
+ const scoreOf = (k) => (withScore && r.score ? v.number(r.score[k]) : "");
936
+ const pensOf = (k) => (withScore && r.pens ? `(${v.number(r.pens[k])})` : "");
937
+ const hasPens = withScore && r.pens !== null;
938
+ const lay = (m) => {
939
+ const cf = clamp(m * 0.24, 7, 11);
940
+ let sf = clamp(m * 0.5, 9, 24);
941
+ const pf = (size) => Math.max(size * 0.55, 8);
942
+ // The score and the shoot-out's score share a line where they fit the column; else the shoot-out goes under the score.
943
+ const need = (size) => Math.max(...[0, 1].map((k) => boldWidth(scoreOf(k), size) + (pensOf(k) ? boldWidth(pensOf(k), pf(size)) + 3 : 0)));
944
+ if (withScore)
945
+ while (sf > 10 && need(sf) > colW - 2)
946
+ sf -= 0.5;
947
+ const inline = !withScore || need(sf) <= colW - 2;
948
+ const height = tagH + m + (plan.codes ? 3 + cf * 1.25 : 0) + (withScore ? 3 + sf * 1.15 + (hasPens && !inline ? pf(sf) * 1.15 : 0) : 0);
949
+ return { cf, sf, pf: pf(sf), inline, height };
950
+ };
951
+ let m = Math.max(10, Math.min(colW - 3, 56, h - 2 * padY));
952
+ let L = lay(m);
953
+ while (m > 10 && L.height > h - 2 * padY) {
954
+ m -= 0.5;
955
+ L = lay(m);
956
+ }
957
+ const top = y + (h - L.height) / 2;
958
+ const midY = top + tagH + m / 2;
579
959
  rowsOrder.forEach((k, n) => {
580
960
  const team = tie.sides[k];
581
- const cx = x + 6 + colW / 2 + n * colW;
582
- const cy = top + m / 2;
583
- const inner = [drawMark(team, cx, cy, withScore ? m : markH, ctx)];
584
- if (withScore && r.score) {
585
- const pens = r.pens ? ` (${v.number(r.pens[k])})` : "";
586
- inner.push(`<text class="tb-score" x="${num(cx)}" y="${num(top + m + sf * 1.05)}" font-size="${num(sf)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(v.number(r.score[k]))}${r.pens ? `<tspan class="tb-muted" font-size="${num(sf * 0.6)}" font-weight="600">${esc(pens)}</tspan>` : ""}</text>`);
961
+ const cx = x + padX + colW / 2 + n * colW;
962
+ const inner = [];
963
+ let yy = top;
964
+ if (tagH && tags[k]) {
965
+ const tw = boldWidth(tags[k], 8) + 8;
966
+ inner.push(`<rect class="tb-tagbox" x="${num(cx - tw / 2)}" y="${num(yy)}" width="${num(tw)}" height="11" rx="5.5"/><text class="tb-tag" x="${num(cx)}" y="${num(yy + 8.2)}" font-size="8" text-anchor="middle">${esc(tags[k])}</text>`);
967
+ }
968
+ yy += tagH;
969
+ inner.push(drawMark(team, cx, yy + m / 2, m, ctx));
970
+ yy += m;
971
+ if (plan.codes) {
972
+ yy += 3;
973
+ inner.push(`<text class="tb-code" x="${num(cx)}" y="${num(yy + L.cf * 0.75)}" font-size="${num(L.cf)}" text-anchor="middle">${esc(fit(abbrOf(team, v), L.cf, colW - 2, boldWidth))}</text>`);
974
+ yy += L.cf * 1.25;
975
+ }
976
+ if (withScore) {
977
+ yy += 3;
978
+ const pens = pensOf(k);
979
+ const span = pens && L.inline ? `<tspan class="tb-muted" dx="3" font-size="${num(L.pf)}" font-weight="600">${esc(pens)}</tspan>` : "";
980
+ inner.push(`<text class="tb-score" x="${num(cx)}" y="${num(yy + L.sf * 0.8)}" font-size="${num(L.sf)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(scoreOf(k))}${span}</text>`);
981
+ yy += L.sf * 1.15;
982
+ if (pens && !L.inline)
983
+ inner.push(`<text class="tb-muted" x="${num(cx)}" y="${num(yy + L.pf * 0.8)}" font-size="${num(L.pf)}" font-weight="600" text-anchor="middle">${esc(pens)}</text>`);
587
984
  }
588
985
  parts.push(row(k, `<g class="${cls(k)}">${inner.join("")}</g>`, cx - colW / 2, y, colW, h));
589
986
  });
987
+ // "vs" between the two marks, where they leave room for it.
988
+ const vf = clamp(m * 0.26, 7, 11);
989
+ if (o.vs === true && colW - m >= boldWidth(v.words.roadVs, vf) + 4)
990
+ parts.push(`<text class="tb-vs" x="${num(x + w / 2)}" y="${num(midY + vf * 0.35)}" font-size="${num(vf)}" text-anchor="middle">${esc(v.words.roadVs)}</text>`);
590
991
  }
591
992
  else if (detail === "mark") {
592
993
  const stack = Math.min((h - 8) / 2, w - 8, road ? 60 : 44);
@@ -608,6 +1009,8 @@ function drawBox(p, plan, o, v, ctx, linked) {
608
1009
  const gap = road ? clamp(rowH * 0.18, 5, 10) : 5;
609
1010
  const small = Math.max(road ? scoreFont * 0.55 : font - 2, 8);
610
1011
  const small2 = Math.max(font - 2, 8);
1012
+ const mw = boldWidth;
1013
+ const tf = clamp(font * 0.62, 7, 9.5);
611
1014
  rowsOrder.forEach((k, n) => {
612
1015
  const team = tie.sides[k];
613
1016
  const ry = y + n * rowH;
@@ -618,24 +1021,31 @@ function drawBox(p, plan, o, v, ctx, linked) {
618
1021
  rowParts.push(drawMark(team, x + padX + md / 2, cy, md, ctx));
619
1022
  tx = x + padX + md + gap;
620
1023
  }
1024
+ // The seed tag, a small pill between the mark and the name.
1025
+ const tag = plan.tags ? tagOf(tie, k) : "";
1026
+ if (tag) {
1027
+ const tw = boldWidth(tag, tf) + 8;
1028
+ rowParts.push(`<rect class="tb-tagbox" x="${num(tx)}" y="${num(cy - (tf + 4) / 2)}" width="${num(tw)}" height="${num(tf + 4)}" rx="${num((tf + 4) / 2)}"/><text class="tb-tag" x="${num(tx + tw / 2)}" y="${num(cy + tf * 0.35)}" font-size="${num(tf)}" text-anchor="middle">${esc(tag)}</text>`);
1029
+ tx += tw + 3;
1030
+ }
621
1031
  const score = scoresOn && r.score ? v.number(r.score[k]) : "";
622
1032
  const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
623
1033
  const bye = p.byes.includes(k);
624
1034
  const right = x + w - padX;
625
- const scoreW = score ? textWidth(score, scoreFont) : 0;
626
- const pensW = pensText ? textWidth(pensText, small) + 3 : 0;
627
- const seed = seedsOn && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
1035
+ const scoreW = score ? mw(score, scoreFont) : 0;
1036
+ const pensW = pensText ? mw(pensText, small) + (road ? 4 : 3) : 0;
1037
+ const seed = seedsOn && !road && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
628
1038
  const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
629
1039
  // The word for a bye goes beside the score only where the name still fits beside it.
630
- const byeFits = bye && right - tx - (scoreW + pensW) - (textWidth(v.words.bye, small2) + 6) - 6 >= textWidth(`${seed}${nm}`, font);
1040
+ const byeFits = bye && right - tx - (scoreW + pensW) - (mw(v.words.bye, small2) + 6) - 6 >= mw(`${seed}${nm}`, font);
631
1041
  const byeText = byeFits ? v.words.bye : "";
632
- const byeW = byeText ? textWidth(byeText, small2) : 0;
1042
+ const byeW = byeText ? mw(byeText, small2) : 0;
633
1043
  const avail = right - tx - (scoreW + pensW) - (byeW ? byeW + 6 : 0) - 6;
634
1044
  // A name that does not fit is set a little smaller before it is cut.
635
1045
  let nameFont = font;
636
- while (nameFont > 9.5 && textWidth(`${seed}${nm}`, nameFont) > avail)
1046
+ while (nameFont > 9.5 && mw(`${seed}${nm}`, nameFont) > avail)
637
1047
  nameFont -= 0.5;
638
- const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8));
1048
+ const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8), mw);
639
1049
  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>`);
640
1050
  if (score)
641
1051
  rowParts.push(`<text class="tb-score" x="${num(right)}" y="${num(cy + scoreFont * 0.35)}" font-size="${num(scoreFont)}" text-anchor="end" font-variant-numeric="tabular-nums">${esc(score)}</text>`);
@@ -652,7 +1062,10 @@ function drawBox(p, plan, o, v, ctx, linked) {
652
1062
  parts.push(`<text class="tb-muted${road ? " tb-note" : ""}" x="${num(x + w / 2)}" y="${num(y + h - (road ? 5 : 4))}" font-size="${f}"${road ? ' font-weight="700" letter-spacing=".05em"' : ""} text-anchor="middle">${esc(fit(line, f * (road ? 1.1 : 1), w - 8))}</text>`);
653
1063
  }
654
1064
  }
655
- const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}`;
1065
+ const extra = tieClassOf(o, tie);
1066
+ // The layouts of their own colour a half by its side: the first half (the left, or the top) is `tb-c0`, the second `tb-c1`.
1067
+ const conf = plan.variant !== null && (side === "l" || side === "r") ? ` tb-c${side === "l" ? 0 : 1}` : "";
1068
+ const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}${conf}${extra}`;
656
1069
  const body = boxHref !== null ? anchor(boxHref, description, parts.join("")) : parts.join("");
657
1070
  return `<g class="${cls2}"><title>${esc(description)}</title>${body}</g>`;
658
1071
  }