@johnmorrisdotca/taisen-brackets 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ import { abbrOf } from "./matchup.js";
2
+ import { drawMark } from "./marks.js";
3
+ import { emWidth, esc, fit, num } from "./text.js";
4
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
5
+ /** The width of a text set in bold at a size: the package's generous estimate, so that a text stays inside its place in any font. */
6
+ const bw = (text, size) => emWidth(text) * size;
7
+ const scoreOf = (e, k) => (e.scoresOn && e.tie.result.score ? e.v.number(e.tie.result.score[k]) : "");
8
+ const pensOf = (e, k) => (e.scoresOn && e.tie.result.pens ? `(${e.v.number(e.tie.result.pens[k])})` : "");
9
+ /** A small pill with a seed tag, its left edge at `x`, centred on `cy`. */
10
+ function tagPill(text, x, cy, size) {
11
+ const w = bw(text, size) + 8;
12
+ return { w, svg: `<rect class="tb-tagbox" x="${num(x)}" y="${num(cy - (size + 4) / 2)}" width="${num(w)}" height="${num(size + 4)}" rx="${num((size + 4) / 2)}"/><text class="tb-tag" x="${num(x + w / 2)}" y="${num(cy + size * 0.35)}" font-size="${num(size)}" text-anchor="middle">${esc(text)}</text>` };
13
+ }
14
+ /** The shield's outline as a path, `size` across and as high, its top left at (x, y). */
15
+ export function shieldPath(x, y, size) {
16
+ const p = (u, v) => `${num(x + u * size)} ${num(y + v * size)}`;
17
+ return `M${p(0, 0)}H${num(x + size)}V${num(y + 0.52 * size)}C${p(1, 0.8)} ${p(0.74, 0.95)} ${p(0.5, 1)}C${p(0.26, 0.95)} ${p(0, 0.8)} ${p(0, 0.52)}Z`;
18
+ }
19
+ /** The shield every mark of the drawing is clipped to, written once. Its path is in the unit square of whatever it clips. */
20
+ function shieldClip(ctx) {
21
+ const id = `${ctx.prefix}-sh`;
22
+ ctx.defs ??= new Map();
23
+ if (!ctx.defs.has(id))
24
+ ctx.defs.set(id, `<clipPath id="${id}" clipPathUnits="objectBoundingBox"><path d="M0 0H1V.52C1 .8 .74 .95 .5 1C.26 .95 0 .8 0 .52Z"/></clipPath>`);
25
+ return id;
26
+ }
27
+ /** The parts of one box, after its own `<rect class="tb-box">`. */
28
+ export function paintVariant(e) {
29
+ // A box too narrow for a mark and a slot is its two marks stacked with their codes and scores, as the shields are.
30
+ if (e.w < 72 && e.kind !== "shields")
31
+ return shields(e);
32
+ switch (e.kind) {
33
+ case "rows": return rows(e);
34
+ case "shields": return shields(e);
35
+ case "update": return update(e);
36
+ case "columns": return columns(e);
37
+ case "series": return series(e);
38
+ }
39
+ }
40
+ // ---------------------------------------------------------------------------------------------- rows
41
+ /** Each team a row: its name on the left, its mark on the right, a slot for the result at the end that faces the centre; a seed tag at the outer corner. What does not fit is left out, the tag first, then the name's length, then the name. */
42
+ function rows(e) {
43
+ const { x, y, w, h, tie, v, ctx } = e;
44
+ const out = [];
45
+ const rowH = h / 2;
46
+ const toRight = !(e.side === "r" && !e.vertical);
47
+ const padX = e.road ? clamp(rowH * 0.16, 5, 10) : 6;
48
+ const gap = 5;
49
+ const font = clamp(rowH * (e.road ? 0.46 : 0.5), 8.5, e.road ? 18 : 15);
50
+ const sf = clamp(rowH * (e.road ? 0.58 : 0.5), 9, e.road ? 20 : 15);
51
+ const tf = clamp(font * 0.62, 7, 9.5);
52
+ const scoreTexts = [0, 1].map((k) => scoreOf(e, k) + pensOf(e, k));
53
+ const slotW = e.scoresOn ? clamp(Math.max(...scoreTexts.map((t) => bw(t, sf))) + 12, 24, 46) : 0;
54
+ const tagWide = e.tags && e.em.tag > 0 ? e.em.tag * tf + 12 : 0;
55
+ const mdMax = ctx.shape === "none" ? 0 : clamp(rowH - (e.road ? 6 : 9), 10, e.road ? 40 : 28);
56
+ const mdMin = mdMax === 0 ? 0 : Math.min(mdMax, 16);
57
+ const nameOf = (k, how) => (tie.sides[k] ? (how === "full" ? tie.sides[k].name : abbrOf(tie.sides[k], v)) : v.words.tbd);
58
+ // The fullest set of parts that fits: the names as they are, then as three letters, then none; each at the size the row gives it, or a smaller one; with the seed tag where there is room for it.
59
+ const fixed = 2 * padX + slotW + (e.scoresOn ? gap : 0);
60
+ const nominal = Math.min(font, 14);
61
+ const tries = [];
62
+ const hows = e.detail === "full" ? ["full", "abbr", null] : e.detail === "compact" ? ["abbr", null] : [null];
63
+ for (const how of hows)
64
+ for (const size of how === null ? [0] : [nominal, 10])
65
+ for (const tag of tagWide > 0 ? [true, false] : [false])
66
+ tries.push({ how, size, tag });
67
+ let chosen = { how: null, size: 0, tag: tagWide > 0 && w - fixed - tagWide - mdMin >= 0, md: Math.max(Math.min(mdMax, w - fixed), 0) };
68
+ for (const t of tries) {
69
+ const need = fixed + (t.tag ? tagWide : 0) + mdMin + (t.how ? gap + (t.how === "full" ? e.em.full : e.em.abbr) * t.size : 0);
70
+ if (need <= w) {
71
+ const spare = w - need;
72
+ chosen = { how: t.how, size: t.size, tag: t.tag, md: mdMax === 0 ? 0 : Math.min(mdMax, mdMin + spare) };
73
+ break;
74
+ }
75
+ }
76
+ if (mdMax > 0 && chosen.md < 8)
77
+ chosen.md = 0;
78
+ const md = chosen.md;
79
+ e.order.forEach((k, n) => {
80
+ const team = tie.sides[k];
81
+ const ry = y + n * rowH;
82
+ const cy = ry + rowH / 2;
83
+ const parts = [];
84
+ const outer = toRight ? x + padX : x + w - padX;
85
+ const slotX = toRight ? x + w - padX - slotW : x + padX;
86
+ if (e.scoresOn) {
87
+ const slotH = Math.min(rowH - 6, e.road ? 34 : 26);
88
+ parts.push(`<rect class="tb-slot" x="${num(slotX)}" y="${num(cy - slotH / 2)}" width="${num(slotW)}" height="${num(slotH)}" rx="${num(Math.min(5, slotH / 3))}"/>`);
89
+ const s = scoreOf(e, k);
90
+ if (s) {
91
+ const pens = pensOf(e, k);
92
+ const span = pens ? `<tspan class="tb-muted" dx="2" font-size="${num(Math.max(sf * 0.6, 7))}" font-weight="600">${esc(pens)}</tspan>` : "";
93
+ parts.push(`<text class="tb-score" x="${num(slotX + slotW / 2)}" y="${num(cy + sf * 0.35)}" font-size="${num(sf)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(s)}${span}</text>`);
94
+ }
95
+ }
96
+ const inner = e.scoresOn ? (toRight ? slotX : slotX + slotW) : toRight ? x + w - padX : x + padX;
97
+ const markCx = toRight ? inner - (e.scoresOn ? gap : 0) - md / 2 : inner + (e.scoresOn ? gap : 0) + md / 2;
98
+ if (md > 0)
99
+ parts.push(drawMark(team, markCx, cy, md, ctx));
100
+ const markEdge = md > 0 ? (toRight ? markCx - md / 2 : markCx + md / 2) : inner;
101
+ let from = outer;
102
+ const tag = chosen.tag ? e.tagOf(tie, k) : "";
103
+ if (tag) {
104
+ const tw = bw(tag, tf) + 8;
105
+ const pill = tagPill(tag, toRight ? from : from - tw, cy, tf);
106
+ parts.push(pill.svg);
107
+ from = toRight ? from + pill.w + 4 : from - pill.w - 4;
108
+ }
109
+ if (chosen.how !== null) {
110
+ const room = Math.max(toRight ? markEdge - gap - from : from - (markEdge + gap), 8);
111
+ const nm = nameOf(k, chosen.how);
112
+ let nf = chosen.size;
113
+ while (nf > 8 && bw(nm, nf) > room)
114
+ nf -= 0.5;
115
+ parts.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(from)}" y="${num(cy + nf * 0.35)}" font-size="${num(nf)}"${toRight ? "" : ' text-anchor="end"'}>${esc(fit(nm, nf, room, bw))}</text>`);
116
+ }
117
+ out.push(e.link(k, `<g class="${e.cls(k)}">${parts.join("")}</g>`, x, ry, w, rowH));
118
+ });
119
+ out.push(`<line class="${e.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"${e.o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
120
+ return out;
121
+ }
122
+ // ---------------------------------------------------------------------------------------------- shields
123
+ /** The two marks in shields with "vs" between, a seed tag over each, its three letters under it, and the score under that; a box too small for all of it leaves the tag, the letters, then the score out. */
124
+ function shields(e) {
125
+ const { x, y, w, h, tie, v, ctx } = e;
126
+ const out = [];
127
+ const padX = 5, padY = 4;
128
+ const colW = (w - 2 * padX) / 2;
129
+ const tags = [0, 1].map((k) => (e.tags ? e.tagOf(tie, k) : ""));
130
+ const haveTag = tags.some((t) => t !== "");
131
+ const haveScore = e.scoresOn && tie.result.score !== null;
132
+ const hasPens = haveScore && tie.result.pens !== null;
133
+ const vsText = v.words.vs;
134
+ const vf = (m) => clamp(m * 0.28, 7, 12);
135
+ const lay = (m, tag, code, score) => {
136
+ const cf = clamp(m * 0.26, 7, 11);
137
+ const sf = clamp(m * 0.42, 9, 22);
138
+ const pf = Math.max(sf * 0.55, 7);
139
+ const tagH = tag ? 12 : 0;
140
+ const height = tagH + m + (code ? 3 + cf * 1.25 : 0) + (score ? 3 + sf * 1.15 + (hasPens ? pf * 1.15 : 0) : 0);
141
+ return { cf, sf, pf, tagH, height, code, score };
142
+ };
143
+ // The fullest set of parts that leaves a mark of at least 10 (at least 8, for the smallest box of all).
144
+ const sets = [[haveTag, true, haveScore], [false, true, haveScore], [false, false, haveScore], [false, false, false]];
145
+ let m = 8;
146
+ let L = lay(m, false, false, false);
147
+ for (const [tag, code, score] of sets) {
148
+ // The marks leave room between them for the "vs".
149
+ const room = Math.min(colW - 2 - (colW >= 26 ? bw(vsText, 10) + 6 : 0), 56, h - 2 * padY);
150
+ let cand = room;
151
+ let got = lay(cand, tag, code, score);
152
+ while (cand > 10 && got.height > h - 2 * padY) {
153
+ cand -= 0.5;
154
+ got = lay(cand, tag, code, score);
155
+ }
156
+ if (cand >= 10 && got.height <= h - 2 * padY) {
157
+ m = cand;
158
+ L = got;
159
+ break;
160
+ }
161
+ }
162
+ if (L.height > h - 2 * padY || m > colW - 2) {
163
+ m = Math.max(6, Math.min(colW - 2, h - 2 * padY));
164
+ L = lay(m, false, false, false);
165
+ }
166
+ const top = y + (h - L.height) / 2;
167
+ const clip = shieldClip(ctx);
168
+ const square = { ...ctx, shape: ctx.shape === "none" ? "none" : "square" };
169
+ e.order.forEach((k, n) => {
170
+ const team = tie.sides[k];
171
+ const cx = x + padX + colW / 2 + n * colW;
172
+ const parts = [];
173
+ let yy = top;
174
+ if (L.tagH && tags[k]) {
175
+ const tw = bw(tags[k], 8) + 8;
176
+ parts.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>`);
177
+ }
178
+ yy += L.tagH;
179
+ const sx = cx - m / 2;
180
+ if (team && ctx.shape !== "none")
181
+ parts.push(`<g clip-path="url(#${clip})">${drawMark(team, cx, yy + m / 2, m, square)}</g>`);
182
+ parts.push(`<path class="tb-shield${team ? "" : " tb-empty"}" d="${shieldPath(sx, yy, m)}"/>`);
183
+ yy += m;
184
+ if (L.code) {
185
+ yy += 3;
186
+ const code = team ? abbrOf(team, v) : v.words.tbd;
187
+ const room = colW - 2;
188
+ if (bw(code, L.cf) <= room)
189
+ parts.push(`<text class="tb-code" x="${num(cx)}" y="${num(yy + L.cf * 0.75)}" font-size="${num(L.cf)}" text-anchor="middle">${esc(code)}</text>`);
190
+ yy += L.cf * 1.25;
191
+ }
192
+ if (L.score) {
193
+ yy += 3;
194
+ parts.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(e, k))}</text>`);
195
+ yy += L.sf * 1.15;
196
+ const pens = pensOf(e, k);
197
+ if (pens)
198
+ parts.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>`);
199
+ }
200
+ out.push(e.link(k, `<g class="${e.cls(k)}">${parts.join("")}</g>`, cx - colW / 2, y, colW, h));
201
+ });
202
+ const f = vf(m);
203
+ if (colW - m >= bw(vsText, f) + 4)
204
+ out.push(`<text class="tb-vs" x="${num(x + w / 2)}" y="${num(top + L.tagH + m / 2 + f * 0.35)}" font-size="${num(f)}" text-anchor="middle">${esc(vsText)}</text>`);
205
+ return out;
206
+ }
207
+ // ---------------------------------------------------------------------------------------------- update
208
+ /** A compact card: each team's abbreviation and seed on the left, its score in a cell on the right; a team not known yet is an empty bar. */
209
+ function update(e) {
210
+ const { x, y, w, h, tie, v } = e;
211
+ const out = [];
212
+ const rowH = h / 2;
213
+ const padX = e.road ? clamp(rowH * 0.18, 5, 10) : 6;
214
+ const font = clamp(rowH * (e.road ? 0.5 : 0.52), 8.5, e.road ? 19 : 15);
215
+ const sf = clamp(rowH * (e.road ? 0.6 : 0.52), 9, e.road ? 20 : 15);
216
+ const cellH = Math.min(rowH - 6, e.road ? 30 : 24);
217
+ const texts = [0, 1].map((k) => scoreOf(e, k));
218
+ const cellW = e.scoresOn ? clamp(Math.max(...texts.map((t) => bw(t, sf))) + 10, cellH, 44) : 0;
219
+ const sm = Math.max(font * 0.72, 7.5);
220
+ e.order.forEach((k, n) => {
221
+ const team = tie.sides[k];
222
+ const ry = y + n * rowH;
223
+ const cy = ry + rowH / 2;
224
+ const parts = [];
225
+ const cellX = x + w - padX - cellW;
226
+ if (e.scoresOn) {
227
+ parts.push(`<rect class="tb-slot" x="${num(cellX)}" y="${num(cy - cellH / 2)}" width="${num(cellW)}" height="${num(cellH)}" rx="${num(Math.min(5, cellH / 3))}"/>`);
228
+ const s = scoreOf(e, k);
229
+ if (s)
230
+ parts.push(`<text class="tb-score" x="${num(cellX + cellW / 2)}" y="${num(cy + sf * 0.35)}" font-size="${num(sf)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(s)}</text>`);
231
+ }
232
+ const right = (e.scoresOn ? cellX - 5 : x + w - padX);
233
+ if (team) {
234
+ const code = abbrOf(team, v);
235
+ const tag = e.tags ? e.tagOf(tie, k) : "";
236
+ let nf = font;
237
+ const need = (size) => bw(code, size) + (tag ? bw(tag, Math.max(size * 0.72, 7.5)) + 5 : 0);
238
+ while (nf > 9 && need(nf) > right - (x + padX))
239
+ nf -= 0.5;
240
+ const shown = fit(code, nf, right - (x + padX), bw);
241
+ parts.push(`<text class="tb-name" x="${num(x + padX)}" y="${num(cy + nf * 0.35)}" font-size="${num(nf)}" font-weight="700">${esc(shown)}</text>`);
242
+ const at = x + padX + bw(shown, nf) + 5;
243
+ const sz = Math.max(nf * 0.72, 7.5);
244
+ if (tag && at + bw(tag, sz) <= right)
245
+ parts.push(`<text class="tb-muted tb-seed" x="${num(at)}" y="${num(cy + sz * 0.35)}" font-size="${num(sz)}" font-weight="600">${esc(tag)}</text>`);
246
+ }
247
+ else {
248
+ parts.push(`<rect class="tb-emptybar" x="${num(x + padX)}" y="${num(cy - 2.5)}" width="${num(Math.max(Math.min(right - (x + padX), 34), 8))}" height="5" rx="2.5"/>`);
249
+ }
250
+ out.push(e.link(k, `<g class="${e.cls(k)}">${parts.join("")}</g>`, x, ry, w, rowH));
251
+ });
252
+ void sm;
253
+ out.push(`<line class="${e.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"${e.o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
254
+ return out;
255
+ }
256
+ // ---------------------------------------------------------------------------------------------- series
257
+ /** The width a one-line series needs: "1 BOS 4–0 MIA 8" set at `size`. */
258
+ export function seriesLineWidth(tie, v, size, scoresOn) {
259
+ const code = (k) => (tie.sides[k] ? abbrOf(tie.sides[k], v) : v.words.tbd);
260
+ const score = scoresOn && tie.result.score ? fillScore(tie, v) : v.words.vs;
261
+ return bw(code(0), size) + bw(code(1), size) + bw(score, size) + 4 * size * 0.6 + 8 * 2 + 2 * bw("00", size * 0.72);
262
+ }
263
+ const fillScore = (tie, v) => (tie.result.score ? v.words.scoreText.replace("{a}", v.number(tie.result.score[0])).replace("{b}", v.number(tie.result.score[1])) : "");
264
+ /** One line a series: "(1) BOS 4–0 MIA (8)", the winner in bold and the loser quiet. Where the box is too narrow for a line, two compact rows. */
265
+ function series(e) {
266
+ const { x, y, w, h, tie, v } = e;
267
+ const font = clamp(h * 0.38, 8.5, e.road ? 16 : 14);
268
+ const need = (size) => seriesLineWidth(tie, v, size, e.scoresOn);
269
+ let size = font;
270
+ while (size > 8.5 && need(size) > w - 8)
271
+ size -= 0.5;
272
+ if (need(size) > w - 8)
273
+ return update(e);
274
+ const [a, b] = e.order;
275
+ const cy = y + h / 2;
276
+ const score = e.scoresOn && tie.result.score ? fillScore(tie, v) : v.words.vs;
277
+ const scoreOrdered = e.scoresOn && tie.result.score && a === 1 ? v.words.scoreText.replace("{a}", v.number(tie.result.score[1])).replace("{b}", v.number(tie.result.score[0])) : score;
278
+ const sw = bw(scoreOrdered, size);
279
+ const cx = x + w / 2;
280
+ const ss = Math.max(size * 0.72, 7.5);
281
+ const part = (k, left) => {
282
+ const team = tie.sides[k];
283
+ const code = team ? abbrOf(team, v) : v.words.tbd;
284
+ const tag = e.tags ? e.tagOf(tie, k) : "";
285
+ const edge = left ? cx - sw / 2 - 6 : cx + sw / 2 + 6;
286
+ const seed = tag ? `<tspan class="tb-muted tb-seed" font-size="${num(ss)}" font-weight="600">${esc(tag)}</tspan>` : "";
287
+ // The left team's seed comes before its code, the right team's after: the two seeds are the line's ends.
288
+ const body = left ? `${seed}${tag ? " " : ""}${esc(code)}` : `${esc(code)}${tag ? " " : ""}${seed}`;
289
+ return `<g class="${e.cls(k)}"><text class="tb-name${team ? "" : " tb-muted"}" x="${num(edge)}" y="${num(cy + size * 0.35)}" font-size="${num(size)}"${left ? ' text-anchor="end"' : ""}>${body}</text></g>`;
290
+ };
291
+ return [
292
+ e.link(a, part(a, true), x, y, w / 2, h),
293
+ `<text class="tb-score tb-series" x="${num(cx)}" y="${num(cy + size * 0.35)}" font-size="${num(size)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(scoreOrdered)}</text>`,
294
+ e.link(b, part(b, false), x + w / 2, y, w / 2, h),
295
+ ];
296
+ }
297
+ // ---------------------------------------------------------------------------------------------- columns
298
+ /** Tall slots, the two sides of a tie one over the other (the final's side by side) with a "VS" or "AT" tag between them, and each team's seed as a big number in its slot. */
299
+ function columns(e) {
300
+ const { x, y, w, h, tie, v, ctx } = e;
301
+ const out = [];
302
+ const pad = 4;
303
+ const tagText = v.upper(e.o.at === true ? v.words.at : v.words.vs);
304
+ const tf = 8;
305
+ const tagW = bw(tagText, tf) + 10;
306
+ const tagH = 13;
307
+ const sideBySide = e.final;
308
+ // `at`: the visiting side is drawn first and the side at home second.
309
+ let order = e.order;
310
+ if (e.o.at === true && tie.home !== undefined && order[1] !== tie.home)
311
+ order = [order[1], order[0]];
312
+ const slots = sideBySide
313
+ ? [0, 1].map((n) => ({ x: x + pad + n * ((w - 2 * pad + (tagW + 6)) / 2), y: y + pad, w: (w - 2 * pad - (tagW + 6)) / 2, h: h - 2 * pad }))
314
+ : [0, 1].map((n) => ({ x: x + pad, y: y + pad + n * ((h - 2 * pad + tagH + 4) / 2), w: w - 2 * pad, h: (h - 2 * pad - tagH - 4) / 2 }));
315
+ order.forEach((k, n) => {
316
+ const team = tie.sides[k];
317
+ const s = slots[n];
318
+ const parts = [];
319
+ parts.push(`<rect class="tb-slot tb-cslot" x="${num(s.x)}" y="${num(s.y)}" width="${num(s.w)}" height="${num(s.h)}" rx="${num(Math.min(6, s.h / 4))}"/>`);
320
+ const rawSeed = tie.sides[k]?.seed ?? e.tagOf(tie, k);
321
+ const seed = rawSeed === undefined || rawSeed === null || rawSeed === "" ? "" : String(rawSeed).slice(0, 3);
322
+ const seedF = seed ? clamp(Math.min(s.h * 0.4, s.w * 0.3), 11, 26) : 0;
323
+ const seedW = seed ? bw(seed, seedF) + 6 : 0;
324
+ // The seed is a big number at the slot's outer edge; the rest is laid out in what is left.
325
+ let cx0 = s.x + 4 + seedW, cw = s.w - 8 - seedW;
326
+ if (seed && cw < 36) {
327
+ cx0 = s.x + 4;
328
+ cw = s.w - 8;
329
+ }
330
+ const showSeed = seed !== "" && cx0 > s.x + 4;
331
+ if (showSeed)
332
+ parts.push(`<text class="tb-seedn" x="${num(s.x + 6)}" y="${num(s.y + s.h / 2 + seedF * 0.35)}" font-size="${num(seedF)}" font-weight="800">${esc(seed)}</text>`);
333
+ const score = scoreOf(e, k);
334
+ const pens = pensOf(e, k);
335
+ const code = team ? abbrOf(team, v) : v.words.tbd;
336
+ const tall = s.h >= 64 && cw >= 34;
337
+ if (tall) {
338
+ const cf = clamp(s.h * 0.15, 8, 12);
339
+ let sf = score ? clamp(s.h * 0.3, 10, 22) : 0;
340
+ const wantMark = ctx.shape === "none" ? 0 : 1;
341
+ const stackH = (m) => m * wantMark + (wantMark ? 3 : 0) + cf * 1.25 + (score ? 3 + sf * 1.15 : 0);
342
+ let m = Math.max(10, Math.min(cw - 4, 46, s.h - 8));
343
+ while (m > 10 && stackH(m) > s.h - 8)
344
+ m -= 0.5;
345
+ while (sf > 10 && stackH(m) > s.h - 8)
346
+ sf -= 0.5;
347
+ let yy = s.y + (s.h - stackH(m)) / 2;
348
+ const cx = cx0 + cw / 2;
349
+ if (wantMark) {
350
+ parts.push(drawMark(team, cx, yy + m / 2, m, ctx));
351
+ yy += m + 3;
352
+ }
353
+ parts.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(cx)}" y="${num(yy + cf * 0.75)}" font-size="${num(cf)}" font-weight="700" text-anchor="middle">${esc(e.detail === "full" && team && bw(team.name, cf) <= cw ? team.name : fit(code, cf, cw, bw))}</text>`);
354
+ yy += cf * 1.25;
355
+ if (score) {
356
+ yy += 3;
357
+ const span = pens ? `<tspan class="tb-muted" dx="2" font-size="${num(Math.max(sf * 0.55, 7))}" font-weight="600">${esc(pens)}</tspan>` : "";
358
+ parts.push(`<text class="tb-score" x="${num(cx)}" y="${num(yy + sf * 0.8)}" font-size="${num(sf)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(score)}${span}</text>`);
359
+ }
360
+ }
361
+ else {
362
+ // A low slot: the mark, the three letters and the score in a row.
363
+ const cy = s.y + s.h / 2;
364
+ const m = ctx.shape === "none" ? 0 : clamp(s.h - 8, 10, 30);
365
+ const sf = clamp(s.h * 0.5, 9, 16);
366
+ const cf = clamp(s.h * 0.4, 8, 13);
367
+ let from = cx0;
368
+ if (m) {
369
+ parts.push(drawMark(team, from + m / 2, cy, m, ctx));
370
+ from += m + 4;
371
+ }
372
+ const scoreW = score ? bw(score + pens, sf) + 4 : 0;
373
+ const room = cx0 + cw - from - scoreW;
374
+ if (room >= 12)
375
+ parts.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(from)}" y="${num(cy + cf * 0.35)}" font-size="${num(cf)}" font-weight="700">${esc(fit(code, cf, room, bw))}</text>`);
376
+ if (score) {
377
+ const span = pens ? `<tspan class="tb-muted" dx="2" font-size="${num(Math.max(sf * 0.55, 7))}" font-weight="600">${esc(pens)}</tspan>` : "";
378
+ parts.push(`<text class="tb-score" x="${num(cx0 + cw)}" y="${num(cy + sf * 0.35)}" font-size="${num(sf)}" text-anchor="end" font-variant-numeric="tabular-nums">${esc(score)}${span}</text>`);
379
+ }
380
+ }
381
+ out.push(e.link(k, `<g class="${e.cls(k)}">${parts.join("")}</g>`, s.x, s.y, s.w, s.h));
382
+ });
383
+ // The tag between the two slots.
384
+ const tcx = sideBySide ? slots[0].x + slots[0].w + (slots[1].x - slots[0].x - slots[0].w) / 2 : x + w / 2;
385
+ const tcy = sideBySide ? y + h / 2 : slots[0].y + slots[0].h + (slots[1].y - slots[0].y - slots[0].h) / 2;
386
+ out.push(`<rect class="tb-atag" x="${num(tcx - tagW / 2)}" y="${num(tcy - tagH / 2)}" width="${num(tagW)}" height="${num(tagH)}" rx="${num(tagH / 2)}"/><text class="tb-atag-t" x="${num(tcx)}" y="${num(tcy + tf * 0.35)}" font-size="${tf}" font-weight="800" letter-spacing=".08em" text-anchor="middle">${esc(tagText)}</text>`);
387
+ return out;
388
+ }
package/dist/locales.js CHANGED
@@ -19,6 +19,8 @@ const EN = {
19
19
  roadCaption: "{a} and {b}: every match on the way to the final. Scores are written with the team in the first column first.",
20
20
  roadName: "{a} and {b}: {title}", roadSummary: "Matches played before the final: {a} ({an}) and {b} ({bn}).",
21
21
  roadFinal: "The final: {a} {score} {b} ({how}).", roadChampion: "{name} won the final.", roadToPlay: "The final is still to be played.",
22
+ vs: "vs", at: "AT", groupName: "Group {n}", colPlayed: "P", colWon: "W", colDrawn: "D", colLost: "L", colDiff: "GD", colPoints: "Pts",
23
+ playIn: "Play-in", playInWinner: "Winner {a}/{b}", playInLoser: "Loser {a}/{b}", seedSlot: "Seed {n}", winnerOf: "Winner of {name}", loserOf: "Loser of {name}",
22
24
  };
23
25
  const FR = {
24
26
  tbd: "À déterminer", bye: "exempt", walkover: "forfait", champion: "Champion", thirdPlace: "Match pour la troisième place", other: "Autres rencontres",
@@ -34,6 +36,8 @@ const FR = {
34
36
  roadCaption: "{a} et {b} : tous les matchs jusqu’à la finale. Chaque score commence par celui de l’équipe de la première colonne.",
35
37
  roadName: "{a} et {b} : {title}", roadSummary: "Matchs joués avant la finale : {a} ({an}) et {b} ({bn}).",
36
38
  roadFinal: "La finale : {a} {score} {b} ({how}).", roadChampion: "{name} a remporté la finale.", roadToPlay: "La finale reste à jouer.",
39
+ vs: "vs", at: "À", groupName: "Groupe {n}", colPlayed: "PJ", colWon: "V", colDrawn: "N", colLost: "D", colDiff: "+/-", colPoints: "Pts",
40
+ playIn: "Play-in", playInWinner: "Gagnant {a}/{b}", playInLoser: "Perdant {a}/{b}", seedSlot: "N°\u00a0{n}", winnerOf: "Gagnant du match {name}", loserOf: "Perdant du match {name}",
37
41
  };
38
42
  const JA = {
39
43
  tbd: "未定", bye: "シード", walkover: "不戦勝", champion: "優勝", thirdPlace: "3位決定戦", other: "その他の対戦",
@@ -49,6 +53,8 @@ const JA = {
49
53
  roadCaption: "{a}と{b}:決勝までの全試合。スコアは「チーム」列のチームの得点を先に書いています。",
50
54
  roadName: "{a}と{b}:{title}", roadSummary: "{a}は決勝までに{an}試合、{b}は{bn}試合を戦いました。",
51
55
  roadFinal: "決勝は{a} {score} {b}({how})でした。", roadChampion: "{name}が優勝しました。", roadToPlay: "決勝はまだ行われていません。",
56
+ vs: "対", at: "@", groupName: "グループ{n}", colPlayed: "試合", colWon: "勝", colDrawn: "分", colLost: "敗", colDiff: "得失差", colPoints: "勝点",
57
+ playIn: "プレイイン", playInWinner: "{a}/{b}の勝者", playInLoser: "{a}/{b}の敗者", seedSlot: "第{n}シード", winnerOf: "{name}の勝者", loserOf: "{name}の敗者",
52
58
  };
53
59
  const ES = {
54
60
  tbd: "Por definir", bye: "exento", walkover: "incomparecencia", champion: "Campeón", thirdPlace: "Partido por el tercer puesto", other: "Otros partidos",
@@ -64,6 +70,8 @@ const ES = {
64
70
  roadCaption: "{a} y {b}: todos los partidos hasta la final. Los marcadores empiezan por el equipo de la primera columna.",
65
71
  roadName: "{a} y {b}: {title}", roadSummary: "Partidos jugados hasta la final: {a} ({an}) y {b} ({bn}).",
66
72
  roadFinal: "La final: {a} {score} {b} ({how}).", roadChampion: "{name} ganó la final.", roadToPlay: "La final aún no se ha jugado.",
73
+ vs: "vs.", at: "EN", groupName: "Grupo {n}", colPlayed: "PJ", colWon: "G", colDrawn: "E", colLost: "P", colDiff: "DG", colPoints: "Pts",
74
+ playIn: "Play-in", playInWinner: "Ganador {a}/{b}", playInLoser: "Perdedor {a}/{b}", seedSlot: "Puesto {n}", winnerOf: "Ganador de {name}", loserOf: "Perdedor de {name}",
67
75
  };
68
76
  const DE = {
69
77
  tbd: "offen", bye: "Freilos", walkover: "kampflos", champion: "Turniersieger", thirdPlace: "Spiel um Platz 3", other: "Weitere Begegnungen",
@@ -79,6 +87,8 @@ const DE = {
79
87
  roadCaption: "{a} und {b}: alle Spiele auf dem Weg ins Finale. Die Ergebnisse beginnen mit dem Team der ersten Spalte.",
80
88
  roadName: "{a} und {b}: {title}", roadSummary: "Spiele bis zum Finale: {a} ({an}) und {b} ({bn}).",
81
89
  roadFinal: "Das Finale: {a} {score} {b} ({how}).", roadChampion: "{name} gewann das Finale.", roadToPlay: "Das Finale steht noch aus.",
90
+ vs: "vs.", at: "BEI", groupName: "Gruppe {n}", colPlayed: "Sp", colWon: "S", colDrawn: "U", colLost: "N", colDiff: "Diff.", colPoints: "Pkt",
91
+ playIn: "Play-in", playInWinner: "Sieger {a}/{b}", playInLoser: "Verlierer {a}/{b}", seedSlot: "Platz {n}", winnerOf: "Sieger von {name}", loserOf: "Verlierer von {name}",
82
92
  };
83
93
  /**
84
94
  * The round names, English name first. The football rounds are the terms of each language's Wikipedia article on the World Cup;
package/dist/marks.d.ts CHANGED
@@ -38,6 +38,8 @@ export interface MarkContext {
38
38
  look?: "classic" | "road";
39
39
  /** Draws a team's `flag` code; with none a team's `mark` or initials are drawn. */
40
40
  flags?: FlagRenderer;
41
+ /** The address of a team's flag by its `flag` code, for a flag that is a picture to load and not markup to inline; tried where `flags` gives none. */
42
+ flagHref?: (code: string) => string | null | undefined;
41
43
  /** The theme a flag renderer is told about. */
42
44
  theme?: FlagRequest["theme"];
43
45
  /** The flags already asked for, by code, shape and size, so that a renderer is called once for each. */
@@ -45,6 +47,8 @@ export interface MarkContext {
45
47
  viewBox: string;
46
48
  inner: string;
47
49
  } | null>;
50
+ /** Other `<defs>` the drawing's shapes need (the shields' clip), each written once under its id. */
51
+ defs?: Map<string, string>;
48
52
  }
49
53
  /** Whether an address may be a link in a drawing: http(s), a relative path or a `#` fragment. `javascript:`, `data:` and every other scheme are refused. */
50
54
  export declare function safeHref(url: string): boolean;
package/dist/marks.js CHANGED
@@ -114,6 +114,7 @@ export function markDefs(ctx) {
114
114
  return [
115
115
  ...[...ctx.radii].sort((a, b) => a - b).map((r) => `<clipPath id="${clipId(ctx.prefix, r)}"><circle r="${num(r)}"/></clipPath>`),
116
116
  ...[...ctx.symbols.values()].map((s) => `<symbol id="${s.id}" viewBox="${esc(s.viewBox)}" preserveAspectRatio="xMidYMid slice">${s.inner}</symbol>`),
117
+ ...(ctx.defs ? [...ctx.defs.values()] : []),
117
118
  ].join("");
118
119
  }
119
120
  /** The id of the circular clip for a radius. */
@@ -152,7 +153,19 @@ export function drawMark(team, cx, cy, size, ctx, abbrText) {
152
153
  }
153
154
  return `${wrapOpen}<use href="#${symbol.id}" x="${num(ox)}" y="${num(oy)}" width="${num(size)}" height="${num(size)}"/></g>${ring}`;
154
155
  }
155
- const mark = team.mark?.trim();
156
+ // A flag that is an address (`flagHref`) is the mark in place of the team's own, and, like every picture address, is written once.
157
+ let fromCode;
158
+ if (typeof ctx.flagHref === "function" && typeof team.flag === "string" && team.flag.trim() !== "") {
159
+ try {
160
+ const href = ctx.flagHref(team.flag.trim());
161
+ if (typeof href === "string" && !href.trim().startsWith("<") && safeUrl(href))
162
+ fromCode = href;
163
+ }
164
+ catch {
165
+ fromCode = undefined;
166
+ }
167
+ }
168
+ const mark = (fromCode ?? team.mark)?.trim();
156
169
  if (mark) {
157
170
  if (mark.startsWith("<")) {
158
171
  const svg = cleanSvg(mark);
@@ -166,10 +179,27 @@ export function drawMark(team, cx, cy, size, ctx, abbrText) {
166
179
  }
167
180
  }
168
181
  else if (safeUrl(mark)) {
169
- return `${wrapOpen}<image href="${esc(mark)}" x="${num(ox)}" y="${num(oy)}" width="${num(size)}" height="${num(size)}" preserveAspectRatio="xMidYMid slice"/></g>${ring}`;
182
+ // Each distinct address is written once, as a symbol that holds the image, and used wherever the team appears: a long data address or a URL repeated per use would be written again and again.
183
+ const key = `url:${mark}`;
184
+ let symbol = ctx.symbols.get(key);
185
+ if (!symbol) {
186
+ symbol = { id: `${ctx.prefix}-m${ctx.symbols.size + 1}`, viewBox: "0 0 100 100", inner: `<image href="${esc(mark)}" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice"/>` };
187
+ ctx.symbols.set(key, symbol);
188
+ }
189
+ return `${wrapOpen}<use href="#${symbol.id}" x="${num(ox)}" y="${num(oy)}" width="${num(size)}" height="${num(size)}"/></g>${ring}`;
170
190
  }
171
191
  }
172
192
  const text = (abbrText ?? team.abbr ?? abbreviate(team.name)).slice(0, 3);
193
+ if (road) {
194
+ // No flag or logo: the road look draws a tile with the abbreviation on it, so that a team without a picture looks meant. A team colour fills the tile (the letters are white or
195
+ // near-black, whichever reads on it); with none the tile is neutral.
196
+ const rx = size * 0.24;
197
+ const tile = `<rect class="tb-plain tb-tile" x="${num(x)}" y="${num(y)}" width="${num(size)}" height="${num(size)}" rx="${num(rx)}"${team.color ? ` style="fill:${esc(team.color)}"` : ""}/>`;
198
+ const tileInk = team.color ? inkOn(team.color) : null;
199
+ const tileRing = `<rect class="tb-ring tb-ring-r" x="${num(x + rw / 2)}" y="${num(y + rw / 2)}" width="${num(size - rw)}" height="${num(size - rw)}" rx="${num(Math.max(rx - rw / 2, 1))}" stroke-width="${num(rw)}"/><rect class="tb-edge" x="${num(x)}" y="${num(y)}" width="${num(size)}" height="${num(size)}" rx="${num(rx)}" stroke-width="${hair}"/>`;
200
+ const letters = [...text].length;
201
+ return `<g class="tb-mark">${tile}<text class="tb-initials" x="${num(cx)}" y="${num(cy + size * 0.13)}" font-size="${num(size * (letters <= 2 ? 0.4 : 0.31))}" font-weight="800" letter-spacing=".02em" text-anchor="middle"${tileInk ? ` style="fill:${tileInk}"` : ""}>${esc(text)}</text></g>${tileRing}`;
202
+ }
173
203
  const fill = team.color ? ` style="fill:${esc(team.color)}"` : "";
174
204
  const shape = clip ? `<circle class="tb-plain" cx="${num(cx)}" cy="${num(cy)}" r="${num(r)}"${fill}/>` : `<rect class="tb-plain" x="${num(x)}" y="${num(y)}" width="${num(size)}" height="${num(size)}" rx="2"${fill}/>`;
175
205
  const ink = team.color ? inkOn(team.color) : null;
package/dist/matchup.js CHANGED
@@ -102,6 +102,8 @@ export function resolveMatchup(m, index, teams) {
102
102
  third: !!m.third,
103
103
  walkover: !!m.walkover,
104
104
  note: m.note ?? null,
105
+ ...(m.seeds ? { seeds: m.seeds } : {}),
106
+ ...(m.home !== undefined ? { home: m.home } : {}),
105
107
  };
106
108
  }
107
109
  /** The short name of a team for a small box. */
@@ -0,0 +1,8 @@
1
+ import type { BracketLayout, DrawOptions, Size } from "./types.ts";
2
+ import { type Voice } from "./locales.ts";
3
+ import { type MarkContext } from "./marks.ts";
4
+ import type { OwnDrawing } from "./groups.ts";
5
+ /** Draws the play-in games of each half. */
6
+ export declare function drawPlayIn(layout: BracketLayout, o: DrawOptions, v: Voice, size: Size, ctx: MarkContext, road: boolean, linked: {
7
+ count: number;
8
+ }): OwnDrawing;