@johnmorrisdotca/taisen-brackets 0.3.0 → 0.5.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
@@ -1,18 +1,75 @@
1
1
  import { layoutBracket } from "./layout.js";
2
2
  import { abbrOf, describeTie, noteParts } from "./matchup.js";
3
- import { cleanSvg, clipId, drawEmblem, drawMark, hasTrophy as hasTrophyOf, isEmblemId, safeUrl } from "./marks.js";
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";
8
- /** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs. */
9
+ /** 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
10
  export const SIZES = Object.freeze({
10
11
  wide: { width: 1200, height: 675 },
11
12
  square: { width: 1080, height: 1080 },
13
+ card: { width: 1080, height: 1350 },
12
14
  phone: { width: 390, height: null },
13
15
  });
14
16
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
15
17
  const isLayout = (x) => typeof x === "object" && x !== null && "ties" in x && "sides" in x && "rounds" in x;
18
+ /** 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. */
19
+ const boldWidth = (text, size) => emWidth(text) * size;
20
+ /** The seed tag of a side: the tie's own, else the team's tag, else its seed number; `""` for none. */
21
+ function tagOf(tie, k) {
22
+ const given = tie.seeds?.[k];
23
+ const team = tie.sides[k];
24
+ const text = given ?? team?.tag ?? team?.seed;
25
+ return text === undefined || text === null ? "" : String(text).slice(0, 4);
26
+ }
27
+ /** 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. */
28
+ function tieClassOf(o, tie) {
29
+ if (typeof o.tieClass !== "function")
30
+ return "";
31
+ let asked;
32
+ try {
33
+ asked = o.tieClass(tie);
34
+ }
35
+ catch {
36
+ return "";
37
+ }
38
+ if (typeof asked !== "string")
39
+ return "";
40
+ return asked.split(/\s+/).filter((word) => /^[A-Za-z_][\w-]*$/.test(word)).map((word) => ` ${word}`).join("");
41
+ }
42
+ /**
43
+ * 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
44
+ * between the edges `lo` and `hi`; `null` when there is none.
45
+ */
46
+ function freeX(segs, y0, y1, w, pref, lo, hi) {
47
+ const margin = 4;
48
+ 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]);
49
+ const ok = (x) => x >= lo && x + w <= hi && blocks.every(([a, b]) => x + w <= a || x >= b);
50
+ if (ok(pref))
51
+ return pref;
52
+ const found = [lo, hi - w, ...blocks.flatMap(([a, b]) => [b, a - w])].filter(ok);
53
+ return found.length === 0 ? null : found.reduce((best, x) => (Math.abs(x - pref) < Math.abs(best - pref) ? x : best));
54
+ }
55
+ /** 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. */
56
+ function metrics(rowH, road) {
57
+ if (road)
58
+ return { font: clamp(rowH * 0.46, 9, 20), md: clamp(rowH * 0.8, 12, 56), score: clamp(rowH * 0.68, 11, 38) };
59
+ const font = clamp(rowH * 0.5, 8.5, 16);
60
+ return { font, md: clamp(rowH * 0.7, 10, 28), score: font + 1 };
61
+ }
62
+ /** The ties a team played on its way to the final: the tree's ties it is a side of, never the third-place match or a round-robin game. */
63
+ function pathOf(layout, team) {
64
+ const out = new Set();
65
+ if (team === undefined || team === null)
66
+ return out;
67
+ const id = String(team);
68
+ for (const t of layout.ties)
69
+ if (!t.third && t.round > 0 && t.sides.some((s) => s !== null && String(s.id) === id))
70
+ out.add(t.index);
71
+ return out;
72
+ }
16
73
  /** The layout with each team's name in the language: what the drawing, the table and the description are written from. */
17
74
  export function localizedLayout(layout, v) {
18
75
  const named = new Map(layout.teams.map((t) => [t.id, { ...t, name: v.team(t) }]));
@@ -42,21 +99,23 @@ export function drawBracket(input, options = {}) {
42
99
  const theme = options.theme ?? "auto";
43
100
  const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
44
101
  const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
45
- const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
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]))}`;
47
- const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round" };
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
102
+ const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
103
+ const road = options.look === "road";
104
+ const hasBg = typeof options.background === "string" || (typeof options.background === "object" && options.background !== null);
105
+ 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"] : [])]))}`;
106
+ 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() };
107
+ const path = pathOf(layout, options.highlightPath);
108
+ const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path);
109
+ const linked = { count: 0 };
110
+ const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked)); // draws the marks, so before the defs are written
50
111
  const title = options.title ?? v.words.title;
51
112
  const desc = describeBracket(layout, options);
52
- const defs = [
53
- ...[...ctx.radii].sort((a, b) => a - b).map((r) => `<clipPath id="${clipId(prefix, r)}"><circle r="${num(r)}"/></clipPath>`),
54
- ...[...ctx.symbols.values()].map((s) => `<symbol id="${s.id}" viewBox="${esc(s.viewBox)}" preserveAspectRatio="xMidYMid slice">${s.inner}</symbol>`),
55
- ].join("");
56
- const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme)}</style>`;
57
- const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>`;
113
+ const picture = resolveBackground(options.background, theme, plan.width, plan.height, prefix);
114
+ const defs = markDefs(ctx) + (picture?.defs ?? "");
115
+ const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme, { road, links: linked.count > 0, follow: path.size > 0, bgAuto: picture?.auto }, picture?.inks)}</style>`;
116
+ const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>${picture?.body ?? ""}`;
58
117
  return [
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">`,
118
+ `<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">`,
60
119
  `<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
61
120
  style,
62
121
  defs ? `<defs>${defs}</defs>` : "",
@@ -69,7 +128,7 @@ export function drawBracket(input, options = {}) {
69
128
  ].filter(Boolean).join("\n");
70
129
  }
71
130
  // ---------------------------------------------------------------------------------------------- the plan
72
- function planBracket(layout, o, v, size, vertical, ctx) {
131
+ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
73
132
  const pad = vertical ? 14 : 22;
74
133
  const showLabels = o.roundLabels ?? true;
75
134
  const poster = o.theme === "poster";
@@ -77,6 +136,11 @@ function planBracket(layout, o, v, size, vertical, ctx) {
77
136
  const links = [];
78
137
  const texts = [];
79
138
  const extras = [];
139
+ // The connector lines as they are drawn, so that the names of the rounds can be placed clear of them.
140
+ const segs = [];
141
+ const addLine = (...pts) => pts.slice(1).forEach(([x, y], i) => segs.push({ x0: pts[i][0], y0: pts[i][1], x1: x, y1: y }));
142
+ const clear = (lo, hi) => (w, pref, y) => freeX(segs, y - 16, y + 7, w, pref, lo, hi);
143
+ const finish = (plan) => ({ ...plan, texts: plan.texts.map((t) => (typeof t === "function" ? t() : t)) });
80
144
  const W0 = size.width;
81
145
  const titleH = (o.title ? (poster ? 46 : 38) : 0) + (o.subtitle ? 24 : 0) + (o.title || o.subtitle ? 10 : 0);
82
146
  const hasGroups = layout.sides.some((s) => s.label);
@@ -102,6 +166,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
102
166
  let others = [];
103
167
  let ow = 0;
104
168
  let extraH0 = 0;
169
+ let labelRoom = showLabels ? 24 : 6;
105
170
  if (!vertical) {
106
171
  const availW = W0 - 2 * pad;
107
172
  const g = 0.2;
@@ -112,13 +177,16 @@ function planBracket(layout, o, v, size, vertical, ctx) {
112
177
  D = clamp(d, 40, 220);
113
178
  gapT = D * g;
114
179
  centreT = Math.max(centreMin, D * 1.2);
115
- const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
180
+ // 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.
181
+ if (showLabels && layout.columns.some((c) => !nameFits(v, road, roundLabel(layout, c, o.roundNames, v), D + gapT * 0.8)))
182
+ labelRoom = 34;
183
+ const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
116
184
  others = othersOf(layout);
117
185
  ow = clamp(D * 1.1, 120, 200);
118
186
  extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
119
187
  const bodyH = size.height === null ? rows * 60 : size.height - top - pad - extraH0;
120
188
  rowStep = bodyH / rows;
121
- S = clamp(rowStep - 9, 24, 104);
189
+ S = clamp(rowStep - (road ? 7 : 9), 24, road ? 132 : 104);
122
190
  rowStep = Math.max(rowStep, S + 6);
123
191
  rowStep = Math.min(rowStep, S + 40);
124
192
  boxW = D;
@@ -127,38 +195,102 @@ function planBracket(layout, o, v, size, vertical, ctx) {
127
195
  else {
128
196
  const availW = W0 - 2 * pad;
129
197
  const slot = availW / Math.max(rows, 1);
130
- S = clamp(slot - 5, 30, 150);
198
+ S = clamp(slot - 5, 30, road ? 340 : 150);
131
199
  rowStep = Math.min(slot, S + 5);
132
200
  boxW = S;
133
201
  }
134
202
  // Full boxes (names) only where the longest name fits beside its mark and a score.
135
- const longest = Math.max(0, ...layout.teams.map((t) => textWidth(t.name, 13)));
203
+ const wide = boldWidth;
204
+ const longest = Math.max(0, ...layout.teams.map((t) => wide(t.name, 13)));
205
+ const longestAbbr = Math.max(0, ...layout.teams.map((t) => wide(abbrOf(t, v), 13)));
206
+ // Seed tags (the road look with `seeds`): a small pill beside each mark, or above it in a box of marks alone.
207
+ const tagsOn = road && (o.seeds ?? false) && layout.ties.some((t) => tagOf(t, 0) !== "" || tagOf(t, 1) !== "");
208
+ const tagW = tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 8))) + 8 : 0;
209
+ // 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.
210
+ const anyNotes = layout.ties.some((t) => noteParts(t, v).length > 0);
211
+ // The road look's boxes of marks alone carry each score under its mark, unless marks alone were asked for.
212
+ const markScores = road && o.detail !== "mark" && (o.scores ?? true) && layout.ties.some((t) => t.result.score !== null);
213
+ const fitOf = (w, h, named) => {
214
+ const rowH = (h - (anyNotes && h >= 62 ? 15 : 0)) / 2;
215
+ const { font, md, score } = metrics(rowH, road);
216
+ const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
217
+ const anyPens = layout.ties.some((t) => t.result.pens !== null);
218
+ 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);
219
+ const fixed = clamp(rowH * 0.16, 5, 12) + clamp(rowH * 0.18, 5, 10) + 8 + 10 + (tagW > 0 ? tagW + 3 : 0);
220
+ const varying = md + (named === "full" ? longest : longestAbbr) * (font / 13) + wide("00", score) + extra;
221
+ return Math.min(1, (w - fixed) / varying);
222
+ };
223
+ let fitNow = 1;
136
224
  const pick = (w, h) => {
137
225
  const rowH = h / 2;
138
- const font = clamp(rowH * 0.5, 8.5, 16);
139
- const md = clamp(rowH * 0.7, 10, 28);
226
+ const { font, md, score } = metrics(rowH, false);
140
227
  // Room for the score, and for the words some boxes carry beside it: a bye, a shoot-out's score.
141
228
  const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
142
229
  const anyPens = layout.ties.some((t) => t.result.pens !== null);
143
- const extra = (anyBye ? textWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? textWidth("(00)", Math.max(font - 2, 8)) + 3 : 0);
144
- const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", font + 1) + extra + 14;
230
+ const extra = (anyBye ? boldWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? boldWidth("(00)", Math.max(font - 2, 8)) + 3 : 0);
231
+ const needed = 6 + md + 5 + longest * (font / 13) + 8 + boldWidth("00", score) + extra + 14;
145
232
  const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
146
233
  return named === "mark" && o.markShape === "none" ? "compact" : named;
147
234
  };
148
- const detail = pick(boxW, vertical ? 48 : boxH);
149
- if (vertical)
150
- boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
235
+ // 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;
236
+ // else the abbreviation (compact) where rows of 18 or more fit; else only the marks.
237
+ const codesOn = road && (o.codes ?? false);
238
+ const markCols = road && (markScores || tagsOn || codesOn || o.vs === true);
239
+ const anyPensAll = layout.ties.some((t) => t.result.pens !== null);
240
+ const roadPick = (w, hMax, grow = false) => {
241
+ const search = (named, hMin) => {
242
+ let h = hMax;
243
+ while (h > hMin && fitOf(w, h, named) < 0.94)
244
+ h -= 2;
245
+ h = Math.max(h, Math.min(hMin, hMax));
246
+ return { detail: named, h, ok: fitOf(w, h, named) >= 0.94 };
247
+ };
248
+ const forced = o.detail && o.detail !== "auto" ? o.detail : null;
249
+ let chosen = null;
250
+ if (forced === "full" || forced === "compact")
251
+ chosen = search(forced, forced === "full" ? 56 : 36);
252
+ else if (forced === null) {
253
+ const full = search("full", 56);
254
+ const compact = search("compact", 36);
255
+ chosen = full.ok ? full : compact.ok ? compact : o.markShape === "none" ? compact : null;
256
+ }
257
+ // 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.
258
+ const m = Math.min((w - 12) / 2 - 3, 56);
259
+ const sf = clamp(m * 0.5, 9, 24);
260
+ 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);
261
+ 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) };
262
+ if (chosen.detail !== "mark")
263
+ fitNow = Math.min(1, fitOf(w, chosen.h, chosen.detail));
264
+ return chosen;
265
+ };
266
+ let detail;
267
+ if (road) {
268
+ const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH, vertical);
269
+ detail = picked.detail;
270
+ if (vertical)
271
+ boxH = picked.h;
272
+ else {
273
+ S = picked.h;
274
+ boxH = S;
275
+ }
276
+ }
277
+ else {
278
+ detail = pick(boxW, vertical ? 48 : boxH);
279
+ if (vertical)
280
+ boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
281
+ }
282
+ // 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.
151
283
  if (vertical) {
152
284
  D = boxH;
153
- gapT = showLabels ? 26 : 16;
285
+ gapT = showLabels ? 32 : 16;
154
286
  }
155
287
  // The champion's block: a trophy, a large mark, the name and the word; and the third-place match below the final.
156
- const champMark = vertical ? 54 : clamp(centreT * 0.34, 44, 84);
288
+ const champMark = vertical ? (road ? clamp(size.width * 0.14, 54, 96) : 54) : clamp(centreT * (road ? 0.4 : 0.34), 44, road ? 96 : 84);
157
289
  const trophyH = hasTrophy ? (vertical ? 56 : clamp(centreT * 0.45, 48, 84)) : 0;
158
290
  const nameFont = clamp(champMark * 0.3, 13, 20);
159
291
  const champH = champ ? trophyH + (hasTrophy ? 6 : 0) + champMark + 8 + nameFont + 6 + 11 + 2 : 0;
160
292
  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;
161
- const finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
293
+ let finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
162
294
  const thirdH = withThird && !bottomThird ? 18 + boxH + 14 : 0;
163
295
  // ----- the slot positions along t
164
296
  if (!vertical) {
@@ -171,7 +303,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
171
303
  sizes.forEach((sz) => { starts.push(t); t += sz + gapT; });
172
304
  const width = Math.max(W0, total + 2 * pad);
173
305
  // ----- span positions
174
- const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
306
+ const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
175
307
  const treeH = rows * rowStep;
176
308
  // The final sits at the middle of the rows; the champion above it and the third place below need room.
177
309
  const upNeed = (champ ? champH + 8 : 0) + 22 + finalH / 2;
@@ -201,14 +333,14 @@ function planBracket(layout, o, v, size, vertical, ctx) {
201
333
  rects.set(layout.final.i, rect);
202
334
  boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, byes: [], final: true, champion: champ });
203
335
  if (showLabels)
204
- texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
336
+ texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
205
337
  if (champ)
206
338
  extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, v, ctx, poster));
207
339
  if (withThird) {
208
340
  const t3 = layout.ties[layout.third];
209
341
  const r3 = { x: midX - finalW / 2, y: rect.y + rect.h + 14 + 18, w: finalW, h: boxH };
210
342
  rects.set(layout.third, r3);
211
- texts.push(label(v, v.words.thirdPlace, midX, r3.y - 6, "middle"));
343
+ texts.push(label(v, road, v.words.thirdPlace, midX, r3.y - 6, "middle"));
212
344
  boxes.push({ rect: r3, tie: t3, order: [0, 1], bye: null, byes: [], final: false, champion: false });
213
345
  }
214
346
  }
@@ -229,7 +361,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
229
361
  const ks = halves ? [slotOfColumn("l", ci), slotOfColumn("r", ci)] : [ci];
230
362
  const used = layout.sides.length > 1 || !halves ? (halves ? [0, 1] : [0]) : [0];
231
363
  ks.forEach((k, n) => { if (used.includes(n) && (!halves || n < layout.sides.length))
232
- texts.push(label(v, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
364
+ texts.push(label(v, road, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
233
365
  }
234
366
  }
235
367
  if (hasGroups) {
@@ -238,7 +370,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
238
370
  return;
239
371
  const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
240
372
  const x0 = Math.min(...ks.map((k) => starts[k])), x1 = Math.max(...ks.map((k) => starts[k] + D));
241
- texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(top - (showLabels ? 36 : 14))}" font-size="${poster ? 16 : 15}" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(poster ? v.upper(sd.label) : sd.label)}</text>`);
373
+ texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(top - (showLabels ? labelRoom + 12 : 14))}" font-size="${poster ? 16 : 15}" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(poster ? v.upper(sd.label) : sd.label)}</text>`);
242
374
  });
243
375
  }
244
376
  // lines
@@ -253,26 +385,49 @@ function planBracket(layout, o, v, size, vertical, ctx) {
253
385
  const x0 = goingRight ? a.x + a.w : a.x, x1 = goingRight ? b.x : b.x + b.w;
254
386
  const y0 = a.y + a.h / 2, y1 = b.y + b.h / 2;
255
387
  const xm = (x0 + x1) / 2;
256
- links.push(`<path class="tb-line" d="M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1)}"${lineAttrs(o)}/>`);
388
+ addLine([x0, y0], [xm, y0], [xm, y1], [x1, y1]);
389
+ 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)}/>`);
257
390
  }
258
391
  if (bottomThird)
259
- thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, boxes, texts);
260
- othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, boxes, texts);
392
+ thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, road, boxes, texts);
393
+ othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, road, boxes, texts);
261
394
  texts.push(...headings(v, o, width, pad, poster));
262
- return { width, height, boxes, links, texts, extras, detail, vertical };
395
+ return finish({ width, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn });
263
396
  }
264
397
  // ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
265
- const bandT = D + gapT;
266
398
  const groupBand = hasGroups ? 26 : 0;
267
399
  // 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.
268
400
  const champTop = halves && champ ? champH + 14 : 0;
269
- const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
270
401
  const topT = pad + titleH + champTop + groupBand + (showLabels ? 14 : 0);
271
402
  const bandsPerSide = nc;
272
- const totalT = halves ? topT + bandsPerSide * bandT + centreBand + bandsPerSide * bandT + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * bandT + (champ ? champH : 0) + pad;
403
+ const bandCount = halves ? 2 * bandsPerSide : nc + (champ ? 1 : 0);
273
404
  others = othersOf(layout);
274
405
  ow = Math.min(W0 - 2 * pad, 150);
275
- const extraV = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
406
+ const othersV = othersHeight(others.length, W0, pad, ow, OTHER_H);
407
+ // 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.
408
+ const needOf = (h, gap) => {
409
+ const finalHere = detail === "mark" ? Math.max(h, 52) : h;
410
+ const centre = (showLabels ? 32 : 0) + 6 + finalHere + 14;
411
+ const thirdV = bottomThird ? 18 + (road ? h : 52) + 16 : 0;
412
+ const band = h + gap;
413
+ return (halves ? topT + bandsPerSide * band + centre + bandsPerSide * band + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * band + (champ ? champH : 0) + pad) + othersV + thirdV;
414
+ };
415
+ // 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.
416
+ if (road && size.height !== null) {
417
+ while (boxH > 40 && needOf(boxH, gapT) > size.height)
418
+ boxH -= 2;
419
+ const slack = size.height - needOf(boxH, gapT);
420
+ if (slack > 0 && bandCount > 0)
421
+ gapT += Math.min(slack / bandCount, boxH * 0.5);
422
+ D = boxH;
423
+ finalH = detail === "mark" ? Math.max(boxH, 52) : boxH;
424
+ }
425
+ const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
426
+ const thirdH2 = road ? boxH : 52;
427
+ const thirdBlockV = bottomThird ? 18 + thirdH2 + 16 : 0;
428
+ const bandT = D + gapT;
429
+ const totalT = needOf(boxH, gapT) - othersV - thirdBlockV;
430
+ const extraV = othersV + thirdBlockV;
276
431
  const height = Math.max(size.height ?? 0, totalT + extraV);
277
432
  const treeW = rows * rowStep;
278
433
  const x0 = pad + (W0 - 2 * pad - treeW) / 2;
@@ -300,7 +455,8 @@ function planBracket(layout, o, v, size, vertical, ctx) {
300
455
  if (champ)
301
456
  extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
302
457
  if (showLabels) {
303
- texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, y + 12, "middle"));
458
+ const at = y + 12;
459
+ texts.push(() => label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, at, "middle", 1e9, clear(pad - 4, W0 - pad + 4)));
304
460
  y += 16;
305
461
  }
306
462
  const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
@@ -315,8 +471,24 @@ function planBracket(layout, o, v, size, vertical, ctx) {
315
471
  layout.columns.forEach((c, ci) => {
316
472
  const name = roundLabel(layout, c, o.roundNames, v);
317
473
  const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
318
- for (const sd of sides)
319
- texts.push(`<text class="tb-round tb-halo" x="${num(pad)}" y="${num(bandStart(sd, ci) - 8)}" font-size="11" font-weight="600" letter-spacing=".08em">${esc(v.upper(name))}</text>`);
474
+ for (const sd of sides) {
475
+ const at = bandStart(sd, ci) - 8;
476
+ texts.push(() => {
477
+ if (road)
478
+ return label(v, road, name, pad, at, "start", 1e9, clear(pad - 4, W0 - pad + 4));
479
+ const shown = v.upper(name);
480
+ let size = 11, left = pad - 4;
481
+ for (const s of [11, 9.5, 8.5]) {
482
+ size = s;
483
+ const found = freeX(segs, at - 16, at + 7, boldWidth(shown, s) * 1.06 + 8, pad - 4, pad - 4, W0 - pad + 4);
484
+ if (found !== null) {
485
+ left = found;
486
+ break;
487
+ }
488
+ }
489
+ 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>`;
490
+ });
491
+ }
320
492
  });
321
493
  }
322
494
  if (hasGroups) {
@@ -337,22 +509,132 @@ function planBracket(layout, o, v, size, vertical, ctx) {
337
509
  const down = b.y > a.y;
338
510
  const y0 = down ? a.y + a.h : a.y, y1 = down ? b.y : b.y + b.h;
339
511
  const x0c = a.x + a.w / 2, x1c = b.x + b.w / 2;
340
- const ym = down ? y0 + gapT * 0.4 : y0 - gapT * 0.4;
341
- links.push(`<path class="tb-line" d="M${num(x0c)} ${num(y0)}V${num(ym)}H${num(x1c)}V${num(y1)}"${lineAttrs(o)}/>`);
512
+ // 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.
513
+ const ym = showLabels ? (down ? y0 + 6 : y1 + 6) : down ? y0 + gapT * 0.4 : y0 - gapT * 0.4;
514
+ addLine([x0c, y0], [x0c, ym], [x1c, ym], [x1c, y1]);
515
+ 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)}/>`);
342
516
  }
343
517
  if (bottomThird)
344
- thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, 200), 52, v, boxes, texts);
345
- othersBlock(layout, others, totalT - pad + thirdBlock + 6, W0, pad, ow, OTHER_H, v, boxes, texts);
518
+ thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, road ? Math.max(boxW, 200) : 200), thirdH2, v, road, boxes, texts);
519
+ othersBlock(layout, others, totalT - pad + thirdBlockV + 6, W0, pad, ow, OTHER_H, v, road, boxes, texts);
346
520
  texts.push(...headings(v, o, W0, pad, poster));
347
- return { width: W0, height, boxes, links, texts, extras, detail, vertical };
521
+ 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 });
522
+ }
523
+ /** A line's class: `tb-path` when both ties it joins are on the highlighted team's path. */
524
+ function lineClass(path, from, to) {
525
+ return path.has(from) && path.has(to) ? "tb-line tb-path" : "tb-line";
526
+ }
527
+ /** The lines of the highlighted path are drawn last, over the others. */
528
+ function orderLinks(links) {
529
+ return [...links.filter((l) => !l.includes("tb-path")), ...links.filter((l) => l.includes("tb-path"))];
348
530
  }
349
531
  const OTHER_H = 50;
350
532
  const centreMinLtr = (d) => d * 1.1;
351
533
  function lineAttrs(o) {
352
534
  return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
353
535
  }
354
- function label(v, text, x, y, anchor, maxWidth = 1e9) {
355
- return `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="11" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(fit(v.upper(text), 11 * 1.18, maxWidth))}</text>`;
536
+ /** 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. */
537
+ function twoLines(text, size, measure) {
538
+ const words = text.split(" ");
539
+ let best = null;
540
+ for (let i = 1; i < words.length; i += 1) {
541
+ const pair = [words.slice(0, i).join(" "), words.slice(i).join(" ")];
542
+ if (best === null || Math.max(measure(pair[0], size), measure(pair[1], size)) < Math.max(measure(best[0], size), measure(best[1], size)))
543
+ best = pair;
544
+ }
545
+ return best;
546
+ }
547
+ /** The bold, letter-spaced width of a round's name (as a pill or a heading sets it). */
548
+ const nameWidth = (t, size) => boldWidth(t, size) * 1.06;
549
+ /**
550
+ * 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
551
+ * 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.
552
+ */
553
+ function nameFits(v, road, text, maxWidth) {
554
+ const upper = v.upper(text);
555
+ return nameWidth(upper, 9) + (road ? 8 : 0) <= maxWidth;
556
+ }
557
+ function label(v, road, text, x, y, anchor, maxWidth = 1e9, place) {
558
+ const upper = v.upper(text);
559
+ if (!road) {
560
+ // 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.
561
+ if (maxWidth < 1e9 && nameWidth(upper, 11) > maxWidth) {
562
+ let size = 11;
563
+ while (size > 9.5 && nameWidth(upper, size) > maxWidth)
564
+ size -= 0.5;
565
+ if (nameWidth(upper, size) <= maxWidth)
566
+ 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>`;
567
+ for (const s of [9.5, 9, 8.5]) {
568
+ const pair = twoLines(upper, s, nameWidth);
569
+ if (pair && Math.max(nameWidth(pair[0], s), nameWidth(pair[1], s)) <= maxWidth)
570
+ 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("");
571
+ }
572
+ 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>`;
573
+ }
574
+ const shown = upper;
575
+ 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>`;
576
+ if (!place)
577
+ return plain;
578
+ // The name is kept clear of the connector lines: where it cannot stand where it was put, it moves along its row.
579
+ for (const size of [11, 9.5, 8.5]) {
580
+ const w = nameWidth(shown, size) + 8;
581
+ const pref = anchor === "middle" ? x - w / 2 : x - 4;
582
+ const left = place(w, pref, y);
583
+ if (left === null)
584
+ continue;
585
+ 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>`;
586
+ }
587
+ return plain;
588
+ }
589
+ // 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.
590
+ let s = 11;
591
+ while (s > 9 && nameWidth(upper, s) > maxWidth - 18)
592
+ s -= 0.5;
593
+ let rows = [upper];
594
+ let side = 9;
595
+ if (nameWidth(upper, s) > maxWidth - 18) {
596
+ if (nameWidth(upper, s) <= maxWidth - 8)
597
+ side = 4;
598
+ else {
599
+ rows = [fit(upper, s, maxWidth - 12, nameWidth)];
600
+ for (const size of [9, 8.5, 8]) {
601
+ const pair = twoLines(upper, size, nameWidth);
602
+ if (pair && Math.max(nameWidth(pair[0], size), nameWidth(pair[1], size)) <= maxWidth - 12) {
603
+ rows = pair;
604
+ s = size;
605
+ side = 6;
606
+ break;
607
+ }
608
+ if (pair && size === 8) {
609
+ rows = pair.map((line) => fit(line, 8, maxWidth - 12, nameWidth));
610
+ s = 8;
611
+ side = 6;
612
+ }
613
+ }
614
+ }
615
+ }
616
+ const widest = Math.max(...rows.map((r) => nameWidth(r, s)));
617
+ let w = widest + 2 * side;
618
+ let x0 = anchor === "middle" ? x - w / 2 : x;
619
+ let fs = s;
620
+ if (place && rows.length === 1) {
621
+ // Where the lines leave no room, the name is set smaller (to 8) and the pill made snugger (side 6, then 4).
622
+ search: for (const size of s > 8 ? [s, 8.5, 8].filter((z) => z <= s) : [s]) {
623
+ for (const sd of [9, 6, 4]) {
624
+ w = nameWidth(rows[0], size) + 2 * sd;
625
+ const pref = anchor === "middle" ? x - w / 2 : x;
626
+ const found = place(w, pref, y);
627
+ x0 = found ?? pref;
628
+ fs = size;
629
+ if (found !== null)
630
+ break search;
631
+ }
632
+ }
633
+ }
634
+ 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>`;
635
+ if (rows.length === 2)
636
+ 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)}`;
637
+ return `<rect class="tb-panel" x="${num(x0)}" y="${num(y - 14.5)}" width="${num(w)}" height="20" rx="10"/>${put(rows[0], y)}`;
356
638
  }
357
639
  function headings(v, o, width, pad, poster) {
358
640
  const out = [];
@@ -401,96 +683,200 @@ function othersHeight(count, width, pad, ow, oh) {
401
683
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
402
684
  return 26 + Math.ceil(count / cols) * (oh + 10) + 6;
403
685
  }
404
- function othersBlock(layout, list, y0, width, pad, ow, oh, v, boxes, texts) {
686
+ function othersBlock(layout, list, y0, width, pad, ow, oh, v, road, boxes, texts) {
405
687
  if (!list.length)
406
688
  return;
407
689
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
408
690
  const x0 = pad + (width - 2 * pad - (Math.min(cols, list.length) * (ow + 10) - 10)) / 2;
409
- texts.push(label(v, v.words.other, width / 2, y0 + 12, "middle"));
691
+ texts.push(label(v, road, v.words.other, width / 2, y0 + 12, "middle"));
410
692
  list.forEach((i, n) => {
411
693
  const rect = { x: x0 + (n % cols) * (ow + 10), y: y0 + 24 + Math.floor(n / cols) * (oh + 10), w: ow, h: oh };
412
694
  boxes.push({ rect, tie: layout.ties[i], order: [0, 1], bye: null, byes: [], final: false, champion: false });
413
695
  });
414
696
  }
415
697
  /** The third-place match with its label, centred on `cx`, its label at `y0`. */
416
- function thirdAtFoot(layout, y0, cx, w, h, v, boxes, texts) {
417
- texts.push(label(v, v.words.thirdPlace, cx, y0 + 12, "middle"));
698
+ function thirdAtFoot(layout, y0, cx, w, h, v, road, boxes, texts) {
699
+ texts.push(label(v, road, v.words.thirdPlace, cx, y0 + 12, "middle"));
418
700
  boxes.push({ rect: { x: cx - w / 2, y: y0 + 18, w, h }, tie: layout.ties[layout.third], order: [0, 1], bye: null, byes: [], final: false, champion: false });
419
701
  }
420
- // ---------------------------------------------------------------------------------------------- one box
421
- function drawBox(p, plan, o, v, ctx) {
702
+ /** The address `href` gives for a tie (or for one side of it), when it is one a drawing may link to; `null` otherwise. */
703
+ function hrefOf(o, tie, side) {
704
+ if (typeof o.href !== "function")
705
+ return null;
706
+ const url = side === undefined ? o.href(tie) : o.href(tie, side);
707
+ return typeof url === "string" && safeHref(url) ? url.trim() : null;
708
+ }
709
+ function drawBox(p, plan, o, v, ctx, linked) {
422
710
  const { rect, tie } = p;
423
711
  const { x, y, w, h } = rect;
424
712
  const detail = plan.detail;
713
+ const road = plan.road;
425
714
  const scoresOn = o.scores ?? true;
426
715
  const seedsOn = o.seeds ?? false;
427
716
  const highlight = o.winnerHighlight ?? true;
717
+ const description = describeTie(tie, v);
718
+ // A link on the whole box wins; with none, each side that has an address is a link on its own row.
719
+ const boxHref = hrefOf(o, tie);
720
+ const sideHref = (k) => (boxHref !== null || tie.sides[k] === null ? null : hrefOf(o, tie, k));
721
+ const anchor = (url, name, inner) => {
722
+ linked.count++;
723
+ return `<a class="tb-link" href="${esc(url)}" aria-label="${esc(name)}">${inner}</a>`;
724
+ };
428
725
  const parts = [];
429
- 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"' : ""}/>`);
726
+ parts.push(`<rect class="tb-box" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(road ? Math.min(14, h / 3.2) : Math.min(7, h / 4))}"${boxHref !== null ? ' pointer-events="all"' : ""}${o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".4"' : ""}/>`);
430
727
  const r = tie.result;
431
728
  const notes = noteParts(tie, v);
432
- const noteH = detail !== "mark" && h >= 50 && notes.length ? 13 : 0;
729
+ // In the road look every box keeps the strip for the note when any tie has one, so that all the rows are the same size.
730
+ const noteH = detail !== "mark" && h >= (road ? 62 : 50) && (road ? plan.notes : notes.length > 0) ? (road ? 15 : 13) : 0;
433
731
  const rowH = (h - noteH) / 2;
434
732
  const rowsOrder = p.order;
435
733
  const cls = (k) => (highlight && tie.winner !== null ? (tie.winner === k ? "tb-won" : "tb-lost") : "tb-pending");
436
- if (detail === "mark") {
437
- const stack = Math.min((h - 8) / 2, w - 8, 44);
438
- const side = Math.min(h - 8, (w - 12) / 2, 44);
734
+ const row = (k, inner, rx, ry, rw, rh) => {
735
+ const url = sideHref(k);
736
+ const team = tie.sides[k];
737
+ if (url === null || team === null)
738
+ return inner;
739
+ 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}`);
740
+ };
741
+ if (detail === "mark" && plan.markCols) {
742
+ // 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.
743
+ const padX = 6, padY = 5;
744
+ const colW = (w - 2 * padX) / 2;
745
+ const withScore = plan.markScores && scoresOn && r.score !== null;
746
+ const tags = [0, 1].map((k) => (plan.tags ? tagOf(tie, k) : ""));
747
+ const tagH = tags.some((t) => t !== "") ? 12 : 0;
748
+ const scoreOf = (k) => (withScore && r.score ? v.number(r.score[k]) : "");
749
+ const pensOf = (k) => (withScore && r.pens ? `(${v.number(r.pens[k])})` : "");
750
+ const hasPens = withScore && r.pens !== null;
751
+ const lay = (m) => {
752
+ const cf = clamp(m * 0.24, 7, 11);
753
+ let sf = clamp(m * 0.5, 9, 24);
754
+ const pf = (size) => Math.max(size * 0.55, 8);
755
+ // The score and the shoot-out's score share a line where they fit the column; else the shoot-out goes under the score.
756
+ const need = (size) => Math.max(...[0, 1].map((k) => boldWidth(scoreOf(k), size) + (pensOf(k) ? boldWidth(pensOf(k), pf(size)) + 3 : 0)));
757
+ if (withScore)
758
+ while (sf > 10 && need(sf) > colW - 2)
759
+ sf -= 0.5;
760
+ const inline = !withScore || need(sf) <= colW - 2;
761
+ const height = tagH + m + (plan.codes ? 3 + cf * 1.25 : 0) + (withScore ? 3 + sf * 1.15 + (hasPens && !inline ? pf(sf) * 1.15 : 0) : 0);
762
+ return { cf, sf, pf: pf(sf), inline, height };
763
+ };
764
+ let m = Math.max(10, Math.min(colW - 3, 56, h - 2 * padY));
765
+ let L = lay(m);
766
+ while (m > 10 && L.height > h - 2 * padY) {
767
+ m -= 0.5;
768
+ L = lay(m);
769
+ }
770
+ const top = y + (h - L.height) / 2;
771
+ const midY = top + tagH + m / 2;
772
+ rowsOrder.forEach((k, n) => {
773
+ const team = tie.sides[k];
774
+ const cx = x + padX + colW / 2 + n * colW;
775
+ const inner = [];
776
+ let yy = top;
777
+ if (tagH && tags[k]) {
778
+ const tw = boldWidth(tags[k], 8) + 8;
779
+ 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>`);
780
+ }
781
+ yy += tagH;
782
+ inner.push(drawMark(team, cx, yy + m / 2, m, ctx));
783
+ yy += m;
784
+ if (plan.codes) {
785
+ yy += 3;
786
+ 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>`);
787
+ yy += L.cf * 1.25;
788
+ }
789
+ if (withScore) {
790
+ yy += 3;
791
+ const pens = pensOf(k);
792
+ const span = pens && L.inline ? `<tspan class="tb-muted" dx="3" font-size="${num(L.pf)}" font-weight="600">${esc(pens)}</tspan>` : "";
793
+ 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>`);
794
+ yy += L.sf * 1.15;
795
+ if (pens && !L.inline)
796
+ 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>`);
797
+ }
798
+ parts.push(row(k, `<g class="${cls(k)}">${inner.join("")}</g>`, cx - colW / 2, y, colW, h));
799
+ });
800
+ // "vs" between the two marks, where they leave room for it.
801
+ const vf = clamp(m * 0.26, 7, 11);
802
+ if (o.vs === true && colW - m >= boldWidth(v.words.roadVs, vf) + 4)
803
+ 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>`);
804
+ }
805
+ else if (detail === "mark") {
806
+ const stack = Math.min((h - 8) / 2, w - 8, road ? 60 : 44);
807
+ const side = Math.min(h - 8, (w - 12) / 2, road ? 60 : 44);
439
808
  const stacked = stack >= side;
440
809
  const md2 = Math.max(stacked ? stack : side, 8);
441
810
  rowsOrder.forEach((k, n) => {
442
811
  const team = tie.sides[k];
443
812
  const cx = stacked ? x + w / 2 : x + w / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 3);
444
813
  const cy = stacked ? y + h / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 1) : y + h / 2;
445
- parts.push(`<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g>`);
814
+ parts.push(row(k, `<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g>`, cx - md2 / 2, cy - md2 / 2, md2, md2));
446
815
  });
447
816
  }
448
817
  else {
449
- const font = clamp(rowH * 0.5, 8.5, 16);
450
- const md = clamp(rowH * 0.7, 10, 28);
818
+ const base = metrics(rowH, road);
819
+ const f = plan.fit;
820
+ const font = road ? Math.max(base.font * f, 8.5) : base.font, md = road ? base.md * f : base.md, scoreFont = road ? base.score * f : base.score;
821
+ const padX = road ? clamp(rowH * 0.16, 5, 12) : 6;
822
+ const gap = road ? clamp(rowH * 0.18, 5, 10) : 5;
823
+ const small = Math.max(road ? scoreFont * 0.55 : font - 2, 8);
824
+ const small2 = Math.max(font - 2, 8);
825
+ const mw = boldWidth;
826
+ const tf = clamp(font * 0.62, 7, 9.5);
451
827
  rowsOrder.forEach((k, n) => {
452
828
  const team = tie.sides[k];
453
829
  const ry = y + n * rowH;
454
830
  const cy = ry + rowH / 2;
455
831
  const rowParts = [];
456
- let tx = x + 6;
832
+ let tx = x + padX;
457
833
  if (o.markShape !== "none") {
458
- rowParts.push(drawMark(team, x + 6 + md / 2, cy, md, ctx));
459
- tx = x + 6 + md + 5;
834
+ rowParts.push(drawMark(team, x + padX + md / 2, cy, md, ctx));
835
+ tx = x + padX + md + gap;
836
+ }
837
+ // The seed tag, a small pill between the mark and the name.
838
+ const tag = plan.tags ? tagOf(tie, k) : "";
839
+ if (tag) {
840
+ const tw = boldWidth(tag, tf) + 8;
841
+ 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>`);
842
+ tx += tw + 3;
460
843
  }
461
844
  const score = scoresOn && r.score ? v.number(r.score[k]) : "";
462
845
  const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
463
846
  const bye = p.byes.includes(k);
464
- const right = x + w - 6;
465
- const scoreW = score ? textWidth(score, font + 1) : 0;
466
- const pensW = pensText ? textWidth(pensText, font - 2) + 3 : 0;
467
- const seed = seedsOn && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
847
+ const right = x + w - padX;
848
+ const scoreW = score ? mw(score, scoreFont) : 0;
849
+ const pensW = pensText ? mw(pensText, small) + (road ? 4 : 3) : 0;
850
+ const seed = seedsOn && !road && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
468
851
  const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
469
852
  // The word for a bye goes beside the score only where the name still fits beside it.
470
- const byeFits = bye && right - tx - (scoreW + pensW) - (textWidth(v.words.bye, Math.max(font - 2, 8)) + 6) - 6 >= textWidth(`${seed}${nm}`, font);
853
+ const byeFits = bye && right - tx - (scoreW + pensW) - (mw(v.words.bye, small2) + 6) - 6 >= mw(`${seed}${nm}`, font);
471
854
  const byeText = byeFits ? v.words.bye : "";
472
- const byeW = byeText ? textWidth(byeText, Math.max(font - 2, 8)) : 0;
855
+ const byeW = byeText ? mw(byeText, small2) : 0;
473
856
  const avail = right - tx - (scoreW + pensW) - (byeW ? byeW + 6 : 0) - 6;
474
857
  // A name that does not fit is set a little smaller before it is cut.
475
858
  let nameFont = font;
476
- while (nameFont > 9.5 && textWidth(`${seed}${nm}`, nameFont) > avail)
859
+ while (nameFont > 9.5 && mw(`${seed}${nm}`, nameFont) > avail)
477
860
  nameFont -= 0.5;
478
- const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8));
861
+ const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8), mw);
479
862
  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>`);
480
863
  if (score)
481
- rowParts.push(`<text class="tb-score" x="${num(right)}" y="${num(cy + (font + 1) * 0.35)}" font-size="${num(font + 1)}" text-anchor="end" font-variant-numeric="tabular-nums">${esc(score)}</text>`);
864
+ 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>`);
482
865
  if (pensText)
483
- rowParts.push(`<text class="tb-muted" x="${num(right - scoreW - 3)}" y="${num(cy + (font - 2) * 0.35)}" font-size="${num(Math.max(font - 2, 8))}" text-anchor="end">${esc(pensText)}</text>`);
866
+ rowParts.push(`<text class="tb-muted" x="${num(right - scoreW - (road ? 4 : 3))}" y="${num(cy + small * 0.35)}" font-size="${num(small)}" text-anchor="end">${esc(pensText)}</text>`);
484
867
  if (byeText)
485
- rowParts.push(`<text class="tb-muted" x="${num(right - (scoreW + pensW) - (scoreW || pensW ? 6 : 0))}" y="${num(cy + (font - 2) * 0.35)}" font-size="${num(Math.max(font - 2, 8))}" font-style="italic" text-anchor="end">${esc(byeText)}</text>`);
486
- parts.push(`<g class="${cls(k)}">${rowParts.join("")}</g>`);
868
+ rowParts.push(`<text class="tb-muted" x="${num(right - (scoreW + pensW) - (scoreW || pensW ? 6 : 0))}" y="${num(cy + small2 * 0.35)}" font-size="${num(small2)}" font-style="italic" text-anchor="end">${esc(byeText)}</text>`);
869
+ parts.push(row(k, `<g class="${cls(k)}">${rowParts.join("")}</g>`, x, ry, w, rowH));
487
870
  });
488
- parts.push(`<line class="tb-line" x1="${num(x + 4)}" x2="${num(x + w - 4)}" y1="${num(y + rowH)}" y2="${num(y + rowH)}" stroke-opacity=".35"${o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
871
+ parts.push(`<line class="${road ? "tb-sep" : "tb-line"}" x1="${num(x + 4)}" x2="${num(x + w - 4)}" y1="${num(y + rowH)}" y2="${num(y + rowH)}" stroke-opacity=".35"${o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
489
872
  if (noteH) {
490
- const f = 9.5;
491
- parts.push(`<text class="tb-muted" x="${num(x + w / 2)}" y="${num(y + h - 4)}" font-size="${f}" text-anchor="middle">${esc(fit(notes.join(" · "), f, w - 8))}</text>`);
873
+ const f = road ? 9 : 9.5;
874
+ const line = road ? v.upper(notes.join(" · ")) : notes.join(" · ");
875
+ 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>`);
492
876
  }
493
877
  }
494
- const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}`;
495
- return `<g class="${cls2}"><title>${esc(describeTie(tie, v))}</title>${parts.join("")}</g>`;
878
+ const extra = tieClassOf(o, tie);
879
+ const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}${extra}`;
880
+ const body = boxHref !== null ? anchor(boxHref, description, parts.join("")) : parts.join("");
881
+ return `<g class="${cls2}"><title>${esc(description)}</title>${body}</g>`;
496
882
  }