@johnmorrisdotca/taisen-brackets 0.2.1 → 0.4.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,37 @@
1
1
  import { layoutBracket } from "./layout.js";
2
2
  import { abbrOf, describeTie, noteParts } from "./matchup.js";
3
- import { cleanSvg, clipId, drawMark, 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
6
  import { esc, fit, hash, num, textWidth } from "./text.js";
7
7
  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. */
8
+ /** 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
9
  export const SIZES = Object.freeze({
10
10
  wide: { width: 1200, height: 675 },
11
11
  square: { width: 1080, height: 1080 },
12
+ card: { width: 1080, height: 1350 },
12
13
  phone: { width: 390, height: null },
13
14
  });
14
15
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
15
16
  const isLayout = (x) => typeof x === "object" && x !== null && "ties" in x && "sides" in x && "rounds" in x;
17
+ /** 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
+ function metrics(rowH, road) {
19
+ if (road)
20
+ return { font: clamp(rowH * 0.46, 9, 20), md: clamp(rowH * 0.8, 12, 56), score: clamp(rowH * 0.68, 11, 38) };
21
+ const font = clamp(rowH * 0.5, 8.5, 16);
22
+ return { font, md: clamp(rowH * 0.7, 10, 28), score: font + 1 };
23
+ }
24
+ /** 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. */
25
+ function pathOf(layout, team) {
26
+ const out = new Set();
27
+ if (team === undefined || team === null)
28
+ return out;
29
+ const id = String(team);
30
+ for (const t of layout.ties)
31
+ if (!t.third && t.round > 0 && t.sides.some((s) => s !== null && String(s.id) === id))
32
+ out.add(t.index);
33
+ return out;
34
+ }
16
35
  /** The layout with each team's name in the language: what the drawing, the table and the description are written from. */
17
36
  export function localizedLayout(layout, v) {
18
37
  const named = new Map(layout.teams.map((t) => [t.id, { ...t, name: v.team(t) }]));
@@ -42,21 +61,21 @@ export function drawBracket(input, options = {}) {
42
61
  const theme = options.theme ?? "auto";
43
62
  const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
44
63
  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
64
+ const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
65
+ 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() };
68
+ const path = pathOf(layout, options.highlightPath);
69
+ const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path);
70
+ const linked = { count: 0 };
71
+ const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked)); // draws the marks, so before the defs are written
50
72
  const title = options.title ?? v.words.title;
51
73
  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>`;
74
+ const defs = markDefs(ctx);
75
+ const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme, { road, links: linked.count > 0, follow: path.size > 0 })}</style>`;
57
76
  const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>`;
58
77
  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">`,
78
+ `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen ${prefix}${road ? " tb-look-road" : ""}${path.size > 0 ? " tb-follow" : ""}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(plan.width)} ${num(plan.height)}" width="${num(plan.width)}" height="${num(plan.height)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${prefix}-t ${prefix}-d">`,
60
79
  `<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
61
80
  style,
62
81
  defs ? `<defs>${defs}</defs>` : "",
@@ -69,7 +88,7 @@ export function drawBracket(input, options = {}) {
69
88
  ].filter(Boolean).join("\n");
70
89
  }
71
90
  // ---------------------------------------------------------------------------------------------- the plan
72
- function planBracket(layout, o, v, size, vertical, ctx) {
91
+ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
73
92
  const pad = vertical ? 14 : 22;
74
93
  const showLabels = o.roundLabels ?? true;
75
94
  const poster = o.theme === "poster";
@@ -88,7 +107,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
88
107
  const halves = layout.shape === "halves";
89
108
  const nc = layout.columns.length;
90
109
  const rows = layout.rows;
91
- const hasTrophy = !!o.trophy && champ;
110
+ const hasTrophy = hasTrophyOf(o) && champ;
92
111
  // ----- slots along the rounds' axis (t): the columns of the left half, the centre, the columns of the right half.
93
112
  // Horizontal: t is x. Vertical: t is y, and the left half is the top.
94
113
  const slotCount = halves ? 2 * nc + 1 : nc + (champ || layout.finalIndex !== null ? 1 : 0);
@@ -118,7 +137,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
118
137
  extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
119
138
  const bodyH = size.height === null ? rows * 60 : size.height - top - pad - extraH0;
120
139
  rowStep = bodyH / rows;
121
- S = clamp(rowStep - 9, 24, 104);
140
+ S = clamp(rowStep - (road ? 7 : 9), 24, road ? 132 : 104);
122
141
  rowStep = Math.max(rowStep, S + 6);
123
142
  rowStep = Math.min(rowStep, S + 40);
124
143
  boxW = D;
@@ -127,38 +146,91 @@ function planBracket(layout, o, v, size, vertical, ctx) {
127
146
  else {
128
147
  const availW = W0 - 2 * pad;
129
148
  const slot = availW / Math.max(rows, 1);
130
- S = clamp(slot - 5, 30, 150);
149
+ S = clamp(slot - 5, 30, road ? 340 : 150);
131
150
  rowStep = Math.min(slot, S + 5);
132
151
  boxW = S;
133
152
  }
134
153
  // Full boxes (names) only where the longest name fits beside its mark and a score.
135
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)));
156
+ // 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
+ const anyNotes = layout.ties.some((t) => noteParts(t, v).length > 0);
158
+ // The road look's boxes of marks alone carry each score under its mark, unless marks alone were asked for.
159
+ const markScores = road && o.detail !== "mark" && (o.scores ?? true) && layout.ties.some((t) => t.result.score !== null);
160
+ const fitOf = (w, h, named) => {
161
+ const rowH = (h - (anyNotes && h >= 62 ? 15 : 0)) / 2;
162
+ const { font, md, score } = metrics(rowH, road);
163
+ const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
164
+ 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;
168
+ return Math.min(1, (w - fixed) / varying);
169
+ };
170
+ let fitNow = 1;
136
171
  const pick = (w, h) => {
137
172
  const rowH = h / 2;
138
- const font = clamp(rowH * 0.5, 8.5, 16);
139
- const md = clamp(rowH * 0.7, 10, 28);
173
+ const { font, md, score } = metrics(rowH, false);
140
174
  // Room for the score, and for the words some boxes carry beside it: a bye, a shoot-out's score.
141
175
  const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
142
176
  const anyPens = layout.ties.some((t) => t.result.pens !== null);
143
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);
144
- const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", font + 1) + extra + 14;
178
+ const needed = 6 + md + 5 + longest * (font / 13) + 8 + textWidth("00", score) + extra + 14;
145
179
  const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
146
180
  return named === "mark" && o.markShape === "none" ? "compact" : named;
147
181
  };
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;
182
+ // 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
+ // else the abbreviation (compact) where rows of 18 or more fit; else only the marks.
184
+ const roadPick = (w, hMax) => {
185
+ const search = (named, hMin) => {
186
+ let h = hMax;
187
+ while (h > hMin && fitOf(w, h, named) < 0.94)
188
+ h -= 2;
189
+ h = Math.max(h, Math.min(hMin, hMax));
190
+ return { detail: named, h, ok: fitOf(w, h, named) >= 0.94 };
191
+ };
192
+ const forced = o.detail && o.detail !== "auto" ? o.detail : null;
193
+ let chosen = null;
194
+ if (forced === "full" || forced === "compact")
195
+ chosen = search(forced, forced === "full" ? 56 : 36);
196
+ else if (forced === null) {
197
+ const full = search("full", 56);
198
+ const compact = search("compact", 36);
199
+ chosen = full.ok ? full : compact.ok ? compact : o.markShape === "none" ? compact : null;
200
+ }
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) };
203
+ if (chosen.detail !== "mark")
204
+ fitNow = Math.min(1, fitOf(w, chosen.h, chosen.detail));
205
+ return chosen;
206
+ };
207
+ let detail;
208
+ if (road) {
209
+ const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH);
210
+ detail = picked.detail;
211
+ if (vertical)
212
+ boxH = picked.h;
213
+ else {
214
+ S = picked.h;
215
+ boxH = S;
216
+ }
217
+ }
218
+ else {
219
+ detail = pick(boxW, vertical ? 48 : boxH);
220
+ if (vertical)
221
+ boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
222
+ }
151
223
  if (vertical) {
152
224
  D = boxH;
153
225
  gapT = showLabels ? 26 : 16;
154
226
  }
155
227
  // 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);
228
+ const champMark = vertical ? (road ? clamp(size.width * 0.14, 54, 96) : 54) : clamp(centreT * (road ? 0.4 : 0.34), 44, road ? 96 : 84);
157
229
  const trophyH = hasTrophy ? (vertical ? 56 : clamp(centreT * 0.45, 48, 84)) : 0;
158
230
  const nameFont = clamp(champMark * 0.3, 13, 20);
159
231
  const champH = champ ? trophyH + (hasTrophy ? 6 : 0) + champMark + 8 + nameFont + 6 + 11 + 2 : 0;
160
232
  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;
233
+ let finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
162
234
  const thirdH = withThird && !bottomThird ? 18 + boxH + 14 : 0;
163
235
  // ----- the slot positions along t
164
236
  if (!vertical) {
@@ -201,14 +273,14 @@ function planBracket(layout, o, v, size, vertical, ctx) {
201
273
  rects.set(layout.final.i, rect);
202
274
  boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, byes: [], final: true, champion: champ });
203
275
  if (showLabels)
204
- texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
276
+ texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
205
277
  if (champ)
206
278
  extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, v, ctx, poster));
207
279
  if (withThird) {
208
280
  const t3 = layout.ties[layout.third];
209
281
  const r3 = { x: midX - finalW / 2, y: rect.y + rect.h + 14 + 18, w: finalW, h: boxH };
210
282
  rects.set(layout.third, r3);
211
- texts.push(label(v, v.words.thirdPlace, midX, r3.y - 6, "middle"));
283
+ texts.push(label(v, road, v.words.thirdPlace, midX, r3.y - 6, "middle"));
212
284
  boxes.push({ rect: r3, tie: t3, order: [0, 1], bye: null, byes: [], final: false, champion: false });
213
285
  }
214
286
  }
@@ -229,7 +301,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
229
301
  const ks = halves ? [slotOfColumn("l", ci), slotOfColumn("r", ci)] : [ci];
230
302
  const used = layout.sides.length > 1 || !halves ? (halves ? [0, 1] : [0]) : [0];
231
303
  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)); });
304
+ texts.push(label(v, road, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
233
305
  }
234
306
  }
235
307
  if (hasGroups) {
@@ -253,26 +325,48 @@ function planBracket(layout, o, v, size, vertical, ctx) {
253
325
  const x0 = goingRight ? a.x + a.w : a.x, x1 = goingRight ? b.x : b.x + b.w;
254
326
  const y0 = a.y + a.h / 2, y1 = b.y + b.h / 2;
255
327
  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)}/>`);
328
+ 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
329
  }
258
330
  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);
331
+ thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, road, boxes, texts);
332
+ othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, road, boxes, texts);
261
333
  texts.push(...headings(v, o, width, pad, poster));
262
- return { width, height, boxes, links, texts, extras, detail, vertical };
334
+ return { width, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores };
263
335
  }
264
336
  // ----- 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
337
  const groupBand = hasGroups ? 26 : 0;
267
338
  // 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
339
  const champTop = halves && champ ? champH + 14 : 0;
269
- const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
270
340
  const topT = pad + titleH + champTop + groupBand + (showLabels ? 14 : 0);
271
341
  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;
342
+ const bandCount = halves ? 2 * bandsPerSide : nc + (champ ? 1 : 0);
273
343
  others = othersOf(layout);
274
344
  ow = Math.min(W0 - 2 * pad, 150);
275
- const extraV = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
345
+ const othersV = othersHeight(others.length, W0, pad, ow, OTHER_H);
346
+ // The height the page needs for boxes `h` high with `gap` between the rounds, and what it keeps for the final's band and the third-place match.
347
+ const needOf = (h, gap) => {
348
+ const finalHere = detail === "mark" ? Math.max(h, 52) : h;
349
+ const centre = (showLabels ? 32 : 0) + 6 + finalHere + 14;
350
+ const thirdV = bottomThird ? 18 + (road ? h : 52) + 16 : 0;
351
+ const band = h + gap;
352
+ return (halves ? topT + bandsPerSide * band + centre + bandsPerSide * band + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * band + (champ ? champH : 0) + pad) + othersV + thirdV;
353
+ };
354
+ // The road look fits a tall page: the boxes are made lower until the rounds fit, or, with room to spare, the room left over is shared between the rounds.
355
+ if (road && size.height !== null) {
356
+ while (boxH > 40 && needOf(boxH, gapT) > size.height)
357
+ boxH -= 2;
358
+ const slack = size.height - needOf(boxH, gapT);
359
+ if (slack > 0 && bandCount > 0)
360
+ gapT += Math.min(slack / bandCount, boxH * 0.5);
361
+ D = boxH;
362
+ finalH = detail === "mark" ? Math.max(boxH, 52) : boxH;
363
+ }
364
+ const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
365
+ const thirdH2 = road ? boxH : 52;
366
+ const thirdBlockV = bottomThird ? 18 + thirdH2 + 16 : 0;
367
+ const bandT = D + gapT;
368
+ const totalT = needOf(boxH, gapT) - othersV - thirdBlockV;
369
+ const extraV = othersV + thirdBlockV;
276
370
  const height = Math.max(size.height ?? 0, totalT + extraV);
277
371
  const treeW = rows * rowStep;
278
372
  const x0 = pad + (W0 - 2 * pad - treeW) / 2;
@@ -300,7 +394,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
300
394
  if (champ)
301
395
  extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
302
396
  if (showLabels) {
303
- texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, y + 12, "middle"));
397
+ texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, y + 12, "middle"));
304
398
  y += 16;
305
399
  }
306
400
  const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
@@ -316,7 +410,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
316
410
  const name = roundLabel(layout, c, o.roundNames, v);
317
411
  const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
318
412
  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>`);
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>`);
320
414
  });
321
415
  }
322
416
  if (hasGroups) {
@@ -338,21 +432,39 @@ function planBracket(layout, o, v, size, vertical, ctx) {
338
432
  const y0 = down ? a.y + a.h : a.y, y1 = down ? b.y : b.y + b.h;
339
433
  const x0c = a.x + a.w / 2, x1c = b.x + b.w / 2;
340
434
  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)}/>`);
435
+ 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
436
  }
343
437
  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);
438
+ thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, road ? Math.max(boxW, 200) : 200), thirdH2, v, road, boxes, texts);
439
+ othersBlock(layout, others, totalT - pad + thirdBlockV + 6, W0, pad, ow, OTHER_H, v, road, boxes, texts);
346
440
  texts.push(...headings(v, o, W0, pad, poster));
347
- return { width: W0, height, boxes, links, texts, extras, detail, vertical };
441
+ return { width: W0, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores };
442
+ }
443
+ /** A line's class: `tb-path` when both ties it joins are on the highlighted team's path. */
444
+ function lineClass(path, from, to) {
445
+ return path.has(from) && path.has(to) ? "tb-line tb-path" : "tb-line";
446
+ }
447
+ /** The lines of the highlighted path are drawn last, over the others. */
448
+ function orderLinks(links) {
449
+ return [...links.filter((l) => !l.includes("tb-path")), ...links.filter((l) => l.includes("tb-path"))];
348
450
  }
349
451
  const OTHER_H = 50;
350
452
  const centreMinLtr = (d) => d * 1.1;
351
453
  function lineAttrs(o) {
352
454
  return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
353
455
  }
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>`;
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.
460
+ const upper = v.upper(text);
461
+ let s = 11;
462
+ while (s > 9 && textWidth(upper, s * 1.18) > maxWidth - 18)
463
+ 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>`;
356
468
  }
357
469
  function headings(v, o, width, pad, poster) {
358
470
  const out = [];
@@ -372,10 +484,13 @@ function championBlock(team, cx, top, mark, trophyH, nameFont, o, v, ctx, poster
372
484
  let y = top;
373
485
  if (o.trophy && trophyH) {
374
486
  const t = o.trophy.trim();
375
- const svg = t.startsWith("<") ? cleanSvg(t) : null;
376
- if (svg)
487
+ const emblem = isEmblemId(t) ? drawEmblem(t, Number(num(trophyH)), o) : null;
488
+ const svg = emblem ? null : t.startsWith("<") ? cleanSvg(t) : null;
489
+ if (emblem)
490
+ out.push(`<svg class="tb-emblem" x="${num(cx - trophyH / 2)}" y="${num(y)}" width="${num(trophyH)}" height="${num(trophyH)}" viewBox="${esc(emblem.viewBox)}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${emblem.inner}</svg>`);
491
+ else if (svg)
377
492
  out.push(`<svg class="tb-gold" x="${num(cx - trophyH / 2)}" y="${num(y)}" width="${num(trophyH)}" height="${num(trophyH)}" viewBox="${esc(svg.viewBox)}" preserveAspectRatio="xMidYMid meet">${svg.inner}</svg>`);
378
- else if (safeUrl(t))
493
+ else if (!isEmblemId(t) && safeUrl(t))
379
494
  out.push(`<image href="${esc(t)}" x="${num(cx - trophyH / 2)}" y="${num(y)}" width="${num(trophyH)}" height="${num(trophyH)}" preserveAspectRatio="xMidYMid meet"/>`);
380
495
  y += trophyH + 6;
381
496
  }
@@ -398,75 +513,123 @@ function othersHeight(count, width, pad, ow, oh) {
398
513
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
399
514
  return 26 + Math.ceil(count / cols) * (oh + 10) + 6;
400
515
  }
401
- function othersBlock(layout, list, y0, width, pad, ow, oh, v, boxes, texts) {
516
+ function othersBlock(layout, list, y0, width, pad, ow, oh, v, road, boxes, texts) {
402
517
  if (!list.length)
403
518
  return;
404
519
  const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
405
520
  const x0 = pad + (width - 2 * pad - (Math.min(cols, list.length) * (ow + 10) - 10)) / 2;
406
- texts.push(label(v, v.words.other, width / 2, y0 + 12, "middle"));
521
+ texts.push(label(v, road, v.words.other, width / 2, y0 + 12, "middle"));
407
522
  list.forEach((i, n) => {
408
523
  const rect = { x: x0 + (n % cols) * (ow + 10), y: y0 + 24 + Math.floor(n / cols) * (oh + 10), w: ow, h: oh };
409
524
  boxes.push({ rect, tie: layout.ties[i], order: [0, 1], bye: null, byes: [], final: false, champion: false });
410
525
  });
411
526
  }
412
527
  /** The third-place match with its label, centred on `cx`, its label at `y0`. */
413
- function thirdAtFoot(layout, y0, cx, w, h, v, boxes, texts) {
414
- texts.push(label(v, v.words.thirdPlace, cx, y0 + 12, "middle"));
528
+ function thirdAtFoot(layout, y0, cx, w, h, v, road, boxes, texts) {
529
+ texts.push(label(v, road, v.words.thirdPlace, cx, y0 + 12, "middle"));
415
530
  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 });
416
531
  }
417
- // ---------------------------------------------------------------------------------------------- one box
418
- function drawBox(p, plan, o, v, ctx) {
532
+ /** The address `href` gives for a tie (or for one side of it), when it is one a drawing may link to; `null` otherwise. */
533
+ function hrefOf(o, tie, side) {
534
+ if (typeof o.href !== "function")
535
+ return null;
536
+ const url = side === undefined ? o.href(tie) : o.href(tie, side);
537
+ return typeof url === "string" && safeHref(url) ? url.trim() : null;
538
+ }
539
+ function drawBox(p, plan, o, v, ctx, linked) {
419
540
  const { rect, tie } = p;
420
541
  const { x, y, w, h } = rect;
421
542
  const detail = plan.detail;
543
+ const road = plan.road;
422
544
  const scoresOn = o.scores ?? true;
423
545
  const seedsOn = o.seeds ?? false;
424
546
  const highlight = o.winnerHighlight ?? true;
547
+ const description = describeTie(tie, v);
548
+ // A link on the whole box wins; with none, each side that has an address is a link on its own row.
549
+ const boxHref = hrefOf(o, tie);
550
+ const sideHref = (k) => (boxHref !== null || tie.sides[k] === null ? null : hrefOf(o, tie, k));
551
+ const anchor = (url, name, inner) => {
552
+ linked.count++;
553
+ return `<a class="tb-link" href="${esc(url)}" aria-label="${esc(name)}">${inner}</a>`;
554
+ };
425
555
  const parts = [];
426
- parts.push(`<rect class="tb-box" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(Math.min(7, h / 4))}"${o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".4"' : ""}/>`);
556
+ 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"' : ""}/>`);
427
557
  const r = tie.result;
428
558
  const notes = noteParts(tie, v);
429
- const noteH = detail !== "mark" && h >= 50 && notes.length ? 13 : 0;
559
+ // 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.
560
+ const noteH = detail !== "mark" && h >= (road ? 62 : 50) && (road ? plan.notes : notes.length > 0) ? (road ? 15 : 13) : 0;
430
561
  const rowH = (h - noteH) / 2;
431
562
  const rowsOrder = p.order;
432
563
  const cls = (k) => (highlight && tie.winner !== null ? (tie.winner === k ? "tb-won" : "tb-lost") : "tb-pending");
433
- if (detail === "mark") {
434
- const stack = Math.min((h - 8) / 2, w - 8, 44);
435
- const side = Math.min(h - 8, (w - 12) / 2, 44);
564
+ const row = (k, inner, rx, ry, rw, rh) => {
565
+ const url = sideHref(k);
566
+ const team = tie.sides[k];
567
+ if (url === null || team === null)
568
+ return inner;
569
+ 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
+ };
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;
579
+ rowsOrder.forEach((k, n) => {
580
+ 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>`);
587
+ }
588
+ parts.push(row(k, `<g class="${cls(k)}">${inner.join("")}</g>`, cx - colW / 2, y, colW, h));
589
+ });
590
+ }
591
+ else if (detail === "mark") {
592
+ const stack = Math.min((h - 8) / 2, w - 8, road ? 60 : 44);
593
+ const side = Math.min(h - 8, (w - 12) / 2, road ? 60 : 44);
436
594
  const stacked = stack >= side;
437
595
  const md2 = Math.max(stacked ? stack : side, 8);
438
596
  rowsOrder.forEach((k, n) => {
439
597
  const team = tie.sides[k];
440
598
  const cx = stacked ? x + w / 2 : x + w / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 3);
441
599
  const cy = stacked ? y + h / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 1) : y + h / 2;
442
- parts.push(`<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g>`);
600
+ parts.push(row(k, `<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g>`, cx - md2 / 2, cy - md2 / 2, md2, md2));
443
601
  });
444
602
  }
445
603
  else {
446
- const font = clamp(rowH * 0.5, 8.5, 16);
447
- const md = clamp(rowH * 0.7, 10, 28);
604
+ const base = metrics(rowH, road);
605
+ const f = plan.fit;
606
+ 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;
607
+ const padX = road ? clamp(rowH * 0.16, 5, 12) : 6;
608
+ const gap = road ? clamp(rowH * 0.18, 5, 10) : 5;
609
+ const small = Math.max(road ? scoreFont * 0.55 : font - 2, 8);
610
+ const small2 = Math.max(font - 2, 8);
448
611
  rowsOrder.forEach((k, n) => {
449
612
  const team = tie.sides[k];
450
613
  const ry = y + n * rowH;
451
614
  const cy = ry + rowH / 2;
452
615
  const rowParts = [];
453
- let tx = x + 6;
616
+ let tx = x + padX;
454
617
  if (o.markShape !== "none") {
455
- rowParts.push(drawMark(team, x + 6 + md / 2, cy, md, ctx));
456
- tx = x + 6 + md + 5;
618
+ rowParts.push(drawMark(team, x + padX + md / 2, cy, md, ctx));
619
+ tx = x + padX + md + gap;
457
620
  }
458
621
  const score = scoresOn && r.score ? v.number(r.score[k]) : "";
459
622
  const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
460
623
  const bye = p.byes.includes(k);
461
- const right = x + w - 6;
462
- const scoreW = score ? textWidth(score, font + 1) : 0;
463
- const pensW = pensText ? textWidth(pensText, font - 2) + 3 : 0;
624
+ const right = x + w - padX;
625
+ const scoreW = score ? textWidth(score, scoreFont) : 0;
626
+ const pensW = pensText ? textWidth(pensText, small) + 3 : 0;
464
627
  const seed = seedsOn && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
465
628
  const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
466
629
  // The word for a bye goes beside the score only where the name still fits beside it.
467
- const byeFits = bye && right - tx - (scoreW + pensW) - (textWidth(v.words.bye, Math.max(font - 2, 8)) + 6) - 6 >= textWidth(`${seed}${nm}`, font);
630
+ const byeFits = bye && right - tx - (scoreW + pensW) - (textWidth(v.words.bye, small2) + 6) - 6 >= textWidth(`${seed}${nm}`, font);
468
631
  const byeText = byeFits ? v.words.bye : "";
469
- const byeW = byeText ? textWidth(byeText, Math.max(font - 2, 8)) : 0;
632
+ const byeW = byeText ? textWidth(byeText, small2) : 0;
470
633
  const avail = right - tx - (scoreW + pensW) - (byeW ? byeW + 6 : 0) - 6;
471
634
  // A name that does not fit is set a little smaller before it is cut.
472
635
  let nameFont = font;
@@ -475,19 +638,21 @@ function drawBox(p, plan, o, v, ctx) {
475
638
  const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8));
476
639
  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>`);
477
640
  if (score)
478
- rowParts.push(`<text class="tb-score" x="${num(right)}" y="${num(cy + (font + 1) * 0.35)}" font-size="${num(font + 1)}" text-anchor="end" font-variant-numeric="tabular-nums">${esc(score)}</text>`);
641
+ 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>`);
479
642
  if (pensText)
480
- 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>`);
643
+ 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>`);
481
644
  if (byeText)
482
- 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>`);
483
- parts.push(`<g class="${cls(k)}">${rowParts.join("")}</g>`);
645
+ 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>`);
646
+ parts.push(row(k, `<g class="${cls(k)}">${rowParts.join("")}</g>`, x, ry, w, rowH));
484
647
  });
485
- 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"' : ""}/>`);
648
+ 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"' : ""}/>`);
486
649
  if (noteH) {
487
- const f = 9.5;
488
- 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>`);
650
+ const f = road ? 9 : 9.5;
651
+ const line = road ? v.upper(notes.join(" · ")) : notes.join(" · ");
652
+ 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>`);
489
653
  }
490
654
  }
491
- const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}`;
492
- return `<g class="${cls2}"><title>${esc(describeTie(tie, v))}</title>${parts.join("")}</g>`;
655
+ const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}`;
656
+ const body = boxHref !== null ? anchor(boxHref, description, parts.join("")) : parts.join("");
657
+ return `<g class="${cls2}"><title>${esc(description)}</title>${body}</g>`;
493
658
  }
@@ -0,0 +1,2 @@
1
+ export { roadToFinal } from "./road.ts";
2
+ export type { RoadChart, RoadDecided, RoadFinal, RoadFinalist, RoadGroupMatch, RoadInput, RoadMatch, RoadOptions, RoadRecord, RoadScore, RoadSize, RoadTeam, RoadTotals } from "./road.ts";
@@ -0,0 +1 @@
1
+ export { roadToFinal } from "./road.js";
package/dist/road.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { RoadChart, RoadInput, RoadOptions } from "./road.types.ts";
2
+ export type { RoadChart, RoadDecided, RoadFinal, RoadFinalist, RoadGroupMatch, RoadInput, RoadMatch, RoadOptions, RoadRecord, RoadScore, RoadSize, RoadTeam, RoadTotals } from "./road.types.ts";
3
+ /**
4
+ * The road to the final: the two finalists as large round marks with their names at the top, each one's matches on its side (the round mark of both teams with the
5
+ * score between them, and FT, AET or the shoot-out under it), the round names down the middle, and the group stage as a compact row of small marks, codes and scores.
6
+ * `input` is a tournament, the same one `drawBracket` draws (a list of matchups, `{ matchups, teams }` or a layout): the two sides of its final are the finalists, each one's
7
+ * played ties (round-robin games, `round: 0`, as the group stage) are its road, and `team` puts one finalist on the left. Or it is the two finalists, each a team (`name`,
8
+ * `abbr`, `mark` or `logo` for the flag or crest, `flag` for a country code drawn by `flags`, `color`) with its `path`, the matches before the final, the earliest first,
9
+ * and optionally its `group`; a match is `{ round, opponent, score: [for, against], decidedBy, pens }`, a round a key (`quarter-final`) with words in every language, or any
10
+ * key you name in `labels`, so a series (`First round`, `4-2`) draws as well as a knockout. A team with no mark is its initials on its colour. The side that won each match
11
+ * is brighter; the sizes are `card` (1080 by 1350, the default), `square`, `wide` (1200 by 675) and `phone` (390 wide). `table()` lists every match as text.
12
+ * @example
13
+ * ```ts
14
+ * import { roadToFinal, type RoadFinalist } from "@johnmorrisdotca/taisen-brackets";
15
+ * const owls: RoadFinalist = { name: "Northern Owls", abbr: "NOW", color: "#3a2d6b", path: [
16
+ * { round: "quarter-final", opponent: { name: "Coastal Pines", abbr: "CPN" }, score: [2, 0] },
17
+ * { round: "semi-final", opponent: { name: "Valley Stars", abbr: "VAL" }, score: [1, 1], decidedBy: "pens", pens: [4, 3] },
18
+ * ] };
19
+ * const gulls: RoadFinalist = { name: "Harbour City Gulls", abbr: "HCG", color: "#1d4f91", path: [
20
+ * { round: "quarter-final", opponent: { name: "Summit Peaks", abbr: "SMT" }, score: [3, 1] },
21
+ * { round: "semi-final", opponent: { name: "Metro Bolts", abbr: "MTB" }, score: [2, 1], decidedBy: "aet" },
22
+ * ] };
23
+ * const chart = roadToFinal([owls, gulls], { title: "Northern Cup 2030", size: "square" });
24
+ * console.log(chart.totals.rounds.join(" "), chart.draw().startsWith("<svg"), chart.table().includes("Semi-final"));
25
+ * // quarter-final semi-final true true
26
+ * ```
27
+ */
28
+ export declare function roadToFinal(input: RoadInput, options?: RoadOptions): RoadChart;
@@ -0,0 +1,4 @@
1
+ import { type Model } from "./road.model.ts";
2
+ import type { RoadOptions } from "./road.types.ts";
3
+ /** Draw the chart as an SVG string. `gid` is the prefix of its ids. */
4
+ export declare function drawRoad(model: Model, o: RoadOptions, gid: string): string;