@johnmorrisdotca/taisen-brackets 0.4.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
@@ -3,7 +3,8 @@ 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";
8
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({
@@ -14,6 +15,43 @@ export const SIZES = Object.freeze({
14
15
  });
15
16
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
16
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
+ }
17
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. */
18
56
  function metrics(rowH, road) {
19
57
  if (road)
@@ -63,17 +101,19 @@ export function drawBracket(input, options = {}) {
63
101
  const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
64
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";
65
103
  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() };
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() };
68
107
  const path = pathOf(layout, options.highlightPath);
69
108
  const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path);
70
109
  const linked = { count: 0 };
71
110
  const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked)); // draws the marks, so before the defs are written
72
111
  const title = options.title ?? v.words.title;
73
112
  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"' : ""}/>`;
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 ?? ""}`;
77
117
  return [
78
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">`,
79
119
  `<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
@@ -96,6 +136,11 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
96
136
  const links = [];
97
137
  const texts = [];
98
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)) });
99
144
  const W0 = size.width;
100
145
  const titleH = (o.title ? (poster ? 46 : 38) : 0) + (o.subtitle ? 24 : 0) + (o.title || o.subtitle ? 10 : 0);
101
146
  const hasGroups = layout.sides.some((s) => s.label);
@@ -121,6 +166,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
121
166
  let others = [];
122
167
  let ow = 0;
123
168
  let extraH0 = 0;
169
+ let labelRoom = showLabels ? 24 : 6;
124
170
  if (!vertical) {
125
171
  const availW = W0 - 2 * pad;
126
172
  const g = 0.2;
@@ -131,7 +177,10 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
131
177
  D = clamp(d, 40, 220);
132
178
  gapT = D * g;
133
179
  centreT = Math.max(centreMin, D * 1.2);
134
- 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;
135
184
  others = othersOf(layout);
136
185
  ow = clamp(D * 1.1, 120, 200);
137
186
  extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
@@ -151,8 +200,12 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
151
200
  boxW = S;
152
201
  }
153
202
  // 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)));
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;
156
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.
157
210
  const anyNotes = layout.ties.some((t) => noteParts(t, v).length > 0);
158
211
  // The road look's boxes of marks alone carry each score under its mark, unless marks alone were asked for.
@@ -162,9 +215,9 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
162
215
  const { font, md, score } = metrics(rowH, road);
163
216
  const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
164
217
  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;
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;
168
221
  return Math.min(1, (w - fixed) / varying);
169
222
  };
170
223
  let fitNow = 1;
@@ -174,14 +227,17 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
174
227
  // Room for the score, and for the words some boxes carry beside it: a bye, a shoot-out's score.
175
228
  const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
176
229
  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;
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;
179
232
  const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
180
233
  return named === "mark" && o.markShape === "none" ? "compact" : named;
181
234
  };
182
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;
183
236
  // else the abbreviation (compact) where rows of 18 or more fit; else only the marks.
184
- const roadPick = (w, hMax) => {
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) => {
185
241
  const search = (named, hMin) => {
186
242
  let h = hMax;
187
243
  while (h > hMin && fitOf(w, h, named) < 0.94)
@@ -198,15 +254,18 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
198
254
  const compact = search("compact", 36);
199
255
  chosen = full.ok ? full : compact.ok ? compact : o.markShape === "none" ? compact : null;
200
256
  }
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) };
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) };
203
262
  if (chosen.detail !== "mark")
204
263
  fitNow = Math.min(1, fitOf(w, chosen.h, chosen.detail));
205
264
  return chosen;
206
265
  };
207
266
  let detail;
208
267
  if (road) {
209
- const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH);
268
+ const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH, vertical);
210
269
  detail = picked.detail;
211
270
  if (vertical)
212
271
  boxH = picked.h;
@@ -220,9 +279,10 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
220
279
  if (vertical)
221
280
  boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
222
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.
223
283
  if (vertical) {
224
284
  D = boxH;
225
- gapT = showLabels ? 26 : 16;
285
+ gapT = showLabels ? 32 : 16;
226
286
  }
227
287
  // The champion's block: a trophy, a large mark, the name and the word; and the third-place match below the final.
228
288
  const champMark = vertical ? (road ? clamp(size.width * 0.14, 54, 96) : 54) : clamp(centreT * (road ? 0.4 : 0.34), 44, road ? 96 : 84);
@@ -243,7 +303,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
243
303
  sizes.forEach((sz) => { starts.push(t); t += sz + gapT; });
244
304
  const width = Math.max(W0, total + 2 * pad);
245
305
  // ----- span positions
246
- const top = pad + titleH + (hasGroups ? 28 : 0) + (showLabels ? 24 : 6);
306
+ const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
247
307
  const treeH = rows * rowStep;
248
308
  // The final sits at the middle of the rows; the champion above it and the third place below need room.
249
309
  const upNeed = (champ ? champH + 8 : 0) + 22 + finalH / 2;
@@ -310,7 +370,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
310
370
  return;
311
371
  const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
312
372
  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>`);
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>`);
314
374
  });
315
375
  }
316
376
  // lines
@@ -325,13 +385,14 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
325
385
  const x0 = goingRight ? a.x + a.w : a.x, x1 = goingRight ? b.x : b.x + b.w;
326
386
  const y0 = a.y + a.h / 2, y1 = b.y + b.h / 2;
327
387
  const xm = (x0 + x1) / 2;
388
+ addLine([x0, y0], [xm, y0], [xm, y1], [x1, y1]);
328
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)}/>`);
329
390
  }
330
391
  if (bottomThird)
331
392
  thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, road, boxes, texts);
332
393
  othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, road, boxes, texts);
333
394
  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 };
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 });
335
396
  }
336
397
  // ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
337
398
  const groupBand = hasGroups ? 26 : 0;
@@ -394,7 +455,8 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
394
455
  if (champ)
395
456
  extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
396
457
  if (showLabels) {
397
- texts.push(label(v, road, 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)));
398
460
  y += 16;
399
461
  }
400
462
  const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
@@ -409,8 +471,24 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
409
471
  layout.columns.forEach((c, ci) => {
410
472
  const name = roundLabel(layout, c, o.roundNames, v);
411
473
  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>`);
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
+ }
414
492
  });
415
493
  }
416
494
  if (hasGroups) {
@@ -431,14 +509,16 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
431
509
  const down = b.y > a.y;
432
510
  const y0 = down ? a.y + a.h : a.y, y1 = down ? b.y : b.y + b.h;
433
511
  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;
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]);
435
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)}/>`);
436
516
  }
437
517
  if (bottomThird)
438
518
  thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, road ? Math.max(boxW, 200) : 200), thirdH2, v, road, boxes, texts);
439
519
  othersBlock(layout, others, totalT - pad + thirdBlockV + 6, W0, pad, ow, OTHER_H, v, road, boxes, texts);
440
520
  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 };
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 });
442
522
  }
443
523
  /** A line's class: `tb-path` when both ties it joins are on the highlighted team's path. */
444
524
  function lineClass(path, from, to) {
@@ -453,18 +533,108 @@ const centreMinLtr = (d) => d * 1.1;
453
533
  function lineAttrs(o) {
454
534
  return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
455
535
  }
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.
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) {
460
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.
461
590
  let s = 11;
462
- while (s > 9 && textWidth(upper, s * 1.18) > maxWidth - 18)
591
+ while (s > 9 && nameWidth(upper, s) > maxWidth - 18)
463
592
  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>`;
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)}`;
468
638
  }
469
639
  function headings(v, o, width, pad, poster) {
470
640
  const out = [];
@@ -568,25 +738,69 @@ function drawBox(p, plan, o, v, ctx, linked) {
568
738
  return inner;
569
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}`);
570
740
  };
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;
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;
579
772
  rowsOrder.forEach((k, n) => {
580
773
  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>`);
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>`);
587
797
  }
588
798
  parts.push(row(k, `<g class="${cls(k)}">${inner.join("")}</g>`, cx - colW / 2, y, colW, h));
589
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>`);
590
804
  }
591
805
  else if (detail === "mark") {
592
806
  const stack = Math.min((h - 8) / 2, w - 8, road ? 60 : 44);
@@ -608,6 +822,8 @@ function drawBox(p, plan, o, v, ctx, linked) {
608
822
  const gap = road ? clamp(rowH * 0.18, 5, 10) : 5;
609
823
  const small = Math.max(road ? scoreFont * 0.55 : font - 2, 8);
610
824
  const small2 = Math.max(font - 2, 8);
825
+ const mw = boldWidth;
826
+ const tf = clamp(font * 0.62, 7, 9.5);
611
827
  rowsOrder.forEach((k, n) => {
612
828
  const team = tie.sides[k];
613
829
  const ry = y + n * rowH;
@@ -618,24 +834,31 @@ function drawBox(p, plan, o, v, ctx, linked) {
618
834
  rowParts.push(drawMark(team, x + padX + md / 2, cy, md, ctx));
619
835
  tx = x + padX + md + gap;
620
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;
843
+ }
621
844
  const score = scoresOn && r.score ? v.number(r.score[k]) : "";
622
845
  const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
623
846
  const bye = p.byes.includes(k);
624
847
  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)} ` : "";
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)} ` : "";
628
851
  const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
629
852
  // 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);
853
+ const byeFits = bye && right - tx - (scoreW + pensW) - (mw(v.words.bye, small2) + 6) - 6 >= mw(`${seed}${nm}`, font);
631
854
  const byeText = byeFits ? v.words.bye : "";
632
- const byeW = byeText ? textWidth(byeText, small2) : 0;
855
+ const byeW = byeText ? mw(byeText, small2) : 0;
633
856
  const avail = right - tx - (scoreW + pensW) - (byeW ? byeW + 6 : 0) - 6;
634
857
  // A name that does not fit is set a little smaller before it is cut.
635
858
  let nameFont = font;
636
- while (nameFont > 9.5 && textWidth(`${seed}${nm}`, nameFont) > avail)
859
+ while (nameFont > 9.5 && mw(`${seed}${nm}`, nameFont) > avail)
637
860
  nameFont -= 0.5;
638
- const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8));
861
+ const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8), mw);
639
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>`);
640
863
  if (score)
641
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>`);
@@ -652,7 +875,8 @@ function drawBox(p, plan, o, v, ctx, linked) {
652
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>`);
653
876
  }
654
877
  }
655
- const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}`;
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}`;
656
880
  const body = boxHref !== null ? anchor(boxHref, description, parts.join("")) : parts.join("");
657
881
  return `<g class="${cls2}"><title>${esc(description)}</title>${body}</g>`;
658
882
  }
package/dist/road.draw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The road chart, drawn: the two finalists' big round marks with their names, the round names down the middle on a dotted road, each finalist's matches on its side as
2
2
  // the round marks of both teams with the score between them, and the group stage as a compact row at the foot. Each size has its own arrangement: `card`, `square` and
3
3
  // `phone` stack the finalists over the rounds; `wide` puts them at the two ends. Nothing is measured in a browser: type is set by estimates (`emWidth`).
4
+ import { resolveBackground } from "./background.js";
4
5
  import { drawMark, markDefs } from "./marks.js";
5
6
  import { codeOf, decidedWords, nameOf, summaryOf } from "./road.model.js";
6
7
  import { SIZES } from "./render.js";
@@ -36,6 +37,7 @@ export function drawRoad(model, o, gid) {
36
37
  shape: o.markShape === "square" ? "square" : "round",
37
38
  look: "road",
38
39
  flags: o.flags,
40
+ flagHref: o.flagHref,
39
41
  theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit",
40
42
  flagCache: new Map(),
41
43
  };
@@ -232,9 +234,10 @@ export function drawRoad(model, o, gid) {
232
234
  if (model.subtitle !== undefined)
233
235
  head += text("tb-r-s", cx, pad + titleFit.height + subSize * 0.7, subSize, model.subtitle);
234
236
  const foot = endSize === 0 ? "" : text("tb-r-e", cx, H - pad - endSize * 0.2, endSize, note);
235
- const defs = markDefs(ctx);
236
- const style = theme === "none" ? "" : `<style>${styleFor(`.${gid}`, theme, { chart: true })}</style>`;
237
- const bg = o.background === false ? "" : `<rect class="tb-bg" x="0" y="0" width="${num(W)}" height="${num(H)}"${theme === "none" ? ' fill="none"' : ""}/>`;
237
+ const picture = resolveBackground(o.background, theme, W, H, gid);
238
+ const defs = markDefs(ctx) + (picture?.defs ?? "");
239
+ const style = theme === "none" ? "" : `<style>${styleFor(`.${gid}`, theme, { chart: true, bgAuto: picture?.auto }, picture?.inks)}</style>`;
240
+ const bg = o.background === false ? "" : `<rect class="tb-bg" x="0" y="0" width="${num(W)}" height="${num(H)}"${theme === "none" ? ' fill="none"' : ""}/>${picture?.body ?? ""}`;
238
241
  const extra = o.className ? ` ${esc(o.className)}` : "";
239
242
  return [
240
243
  `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(model.voice.locale.code)}" class="taisen tb-road ${gid}${extra}" viewBox="0 0 ${num(W)} ${num(H)}" width="${num(W)}" height="${num(H)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${gid}-t ${gid}-d" data-size="${size}">`,
package/dist/road.js CHANGED
@@ -44,7 +44,7 @@ export function roadToFinal(input, options = {}) {
44
44
  const merged = { ...options, ...extra };
45
45
  check(merged);
46
46
  const m = readModel(input, merged);
47
- const prefix = merged.id ?? `tb-${hash(JSON.stringify([m.voice.locale.code, m.finalists.map((t) => t.name), [...m.path[0].values()].map((v) => [v.round, v.mine, v.theirs]), [...m.path[1].values()].map((v) => [v.round, v.mine, v.theirs]), m.rounds, merged.size, merged.theme, merged.title, merged.subtitle, merged.final ?? null, merged.endnote, merged.winner, merged.codes, merged.markShape]))}`;
47
+ const prefix = merged.id ?? `tb-${hash(JSON.stringify([m.voice.locale.code, m.finalists.map((t) => t.name), [...m.path[0].values()].map((v) => [v.round, v.mine, v.theirs]), [...m.path[1].values()].map((v) => [v.round, v.mine, v.theirs]), m.rounds, merged.size, merged.theme, merged.title, merged.subtitle, merged.final ?? null, merged.endnote, merged.winner, merged.codes, merged.markShape, ...(typeof merged.background === "string" || typeof merged.background === "object" ? [merged.background] : [])]))}`;
48
48
  return drawRoad(m, merged, prefix);
49
49
  };
50
50
  return {
@@ -49,7 +49,7 @@ export interface RoadFinal {
49
49
  /** The sizes of the road chart are the bracket's: `card` (the default here), `square`, `wide` (the finalists at the two ends) and `phone`. */
50
50
  export type RoadSize = SizeName;
51
51
  /** What `roadToFinal` takes besides the finalists or the tournament. */
52
- export interface RoadOptions extends Pick<DrawOptions, "locale" | "words" | "teamName" | "roundNames" | "markShape" | "flags" | "theme" | "background" | "id" | "className"> {
52
+ export interface RoadOptions extends Pick<DrawOptions, "locale" | "words" | "teamName" | "roundNames" | "markShape" | "flags" | "flagHref" | "theme" | "background" | "id" | "className"> {
53
53
  /** With a tournament: the finalist whose road is on the left (by team id); the other finalist is its opponent in the final. Left out, the first side of the final. */
54
54
  team?: SideId;
55
55
  /** The tournament, set large at the top. The language's "Road to the final" when missing. */
package/dist/text.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare function esc(text: string | number): string;
7
7
  */
8
8
  export declare function textWidth(text: string, size: number): number;
9
9
  /** The text cut to fit a width, with an ellipsis where it was cut. */
10
- export declare function fit(text: string, size: number, width: number): string;
10
+ export declare function fit(text: string, size: number, width: number, measure?: (text: string, size: number) => number): string;
11
11
  /** A short name for a small box: the initials of a name of several words ("NYR"), else its first three letters; capitals. */
12
12
  export declare function abbreviate(name: string): string;
13
13
  /** A small, stable hash of a string as lowercase base 36, for ids that depend on the content and nothing else. */