@johnmorrisdotca/taisen-brackets 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -6,6 +6,13 @@ import { styleFor } from "./theme.js";
6
6
  import { resolveBackground } from "./background.js";
7
7
  import { emWidth, esc, fit, hash, num } from "./text.js";
8
8
  import { fill, voiceOf } from "./locales.js";
9
+ import { paintVariant, seriesLineWidth } from "./layouts.js";
10
+ import { drawGroups } from "./groups.js";
11
+ import { headTile } from "./furniture.js";
12
+ import { drawPlayIn } from "./playin.js";
13
+ /** The names of the layouts, the tree first. */
14
+ export const LAYOUTS = Object.freeze(["tree", "rows", "shields", "update", "columns", "playin", "series", "groups"]);
15
+ const VARIANTS = ["rows", "shields", "update", "columns", "series"];
9
16
  /** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs; a card is 1080 by 1350, a tall post. */
10
17
  export const SIZES = Object.freeze({
11
18
  wide: { width: 1200, height: 675 },
@@ -95,40 +102,87 @@ export function describeBracket(input, options = {}) {
95
102
  */
96
103
  export function drawBracket(input, options = {}) {
97
104
  const v = voiceOf(options);
98
- const layout = localizedLayout(isLayout(input) ? input : layoutBracket(input, options), v);
105
+ const kind = options.layout ?? "tree";
106
+ if (!LAYOUTS.includes(kind))
107
+ throw new RangeError(`taisen-brackets: unknown layout ${JSON.stringify(String(kind))}: use one of ${LAYOUTS.join(", ")}`);
108
+ // The series card lists the east first: with no order asked for, a group named east goes before one named west.
109
+ const groupOrder = kind === "series" && options.groupOrder === undefined && !isLayout(input) ? eastFirst(input) : options.groupOrder;
110
+ const layout = localizedLayout(isLayout(input) ? input : layoutBracket(input, groupOrder ? { ...options, groupOrder } : options), v);
99
111
  const theme = options.theme ?? "auto";
100
- const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
112
+ // The series card is a tall card unless a size is asked for.
113
+ const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? (kind === "series" ? "card" : "wide") : null;
101
114
  const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
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";
115
+ const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : kind === "series" || sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
103
116
  const road = options.look === "road";
104
117
  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() };
118
+ const prefix = options.id ?? `tb-${hash(JSON.stringify([v.locale.code, layout.teams.map((t) => t.name), layout.ties.map((t) => [t.id, t.sides.map((s) => s?.id ?? null), t.winner, t.result.score, t.round]), options.size, orientation, options.detail, theme, options.title, options.subtitle, options.seeds, options.scores, options.champion, options.markShape, options.roundLabels, options.thirdPlace, options.trophy ? 1 : 0, road ? 1 : 0, options.highlightPath ?? null, options.flags ? 1 : 0, ...(hasBg ? [options.background, options.vs, options.codes, options.seeds] : []), ...(options.flagHref ? ["h"] : []), ...(kind !== "tree" ? [kind, options.at, options.standings, options.qualify] : []), ...(options.headers ? ["head"] : [])]))}`;
119
+ const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round", look: road ? "road" : "classic", flags: options.flags, flagHref: options.flagHref, theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit", flagCache: new Map(), defs: new Map() };
107
120
  const path = pathOf(layout, options.highlightPath);
108
- const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path);
109
121
  const linked = { count: 0 };
110
- const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked)); // draws the marks, so before the defs are written
111
122
  const title = options.title ?? v.words.title;
112
123
  const desc = describeBracket(layout, options);
113
- const picture = resolveBackground(options.background, theme, plan.width, plan.height, prefix);
124
+ // The group tables and the play-in are not the tree: they plan and draw their own parts, and share the frame (the style, the background, the title) with it.
125
+ const own = kind === "groups" ? drawGroups(layout, options, v, size, ctx, road, linked) : kind === "playin" ? drawPlayIn(layout, options, v, size, ctx, road, linked) : null;
126
+ if (own)
127
+ return frame({ prefix, v, options, theme, road, kind, title, desc: own.desc ?? desc, width: own.width, height: own.height, followed: false, links: [], parts: [...own.body, ...own.texts], linkCount: linked.count, ctx });
128
+ const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path, kind);
129
+ const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked, layout.place[b.tie.index])); // draws the marks, so before the defs are written
130
+ return frame({ prefix, v, options, theme, road, kind, title, desc, width: plan.width, height: plan.height, followed: path.size > 0, links: plan.links, parts: [...drawn, ...plan.texts, ...plan.extras], linkCount: linked.count, ctx });
131
+ }
132
+ /** The drawing's frame: the root `<svg>` with its title, its style, the defs the marks wrote and the background, round the parts a layout drew. */
133
+ function frame(f) {
134
+ const { prefix, v, options, theme, road, ctx } = f;
135
+ const picture = resolveBackground(options.background, theme, f.width, f.height, prefix);
114
136
  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 ?? ""}`;
137
+ const scope = scopeOf(options, prefix);
138
+ const style = theme === "none" || options.style === false ? "" : `<style>${styleFor(`.${scope}`, theme, { road, links: f.linkCount > 0, follow: f.followed, bgAuto: picture?.auto, layout: f.kind !== "tree" || options.headers === true }, picture?.inks)}</style>`;
139
+ const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(f.width)}" height="${num(f.height)}"${theme === "none" ? ' fill="none"' : ""}/>${picture?.body ?? ""}`;
117
140
  return [
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">`,
119
- `<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
141
+ `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen ${prefix}${scope !== prefix ? ` ${scope}` : ""}${road ? " tb-look-road" : ""}${f.followed ? " tb-follow" : ""}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(f.width)} ${num(f.height)}" width="${num(f.width)}" height="${num(f.height)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${prefix}-t ${prefix}-d">`,
142
+ `<title id="${prefix}-t">${esc(f.title)}</title><desc id="${prefix}-d">${esc(f.desc)}</desc>`,
120
143
  style,
121
144
  defs ? `<defs>${defs}</defs>` : "",
122
145
  bg,
123
- ...plan.links,
124
- ...drawn,
125
- ...plan.texts,
126
- ...plan.extras,
146
+ ...f.links,
147
+ ...f.parts,
127
148
  "</svg>",
128
149
  ].filter(Boolean).join("\n");
129
150
  }
151
+ /** The class that carries a drawing's rules: its `styleScope` when it has one, else its own id prefix. */
152
+ function scopeOf(o, prefix) {
153
+ if (o.styleScope === undefined)
154
+ return prefix;
155
+ if (typeof o.styleScope !== "string" || !/^[A-Za-z_][\w-]*$/.test(o.styleScope))
156
+ throw new RangeError("taisen-brackets: styleScope is one word of letters, digits, - and _");
157
+ return o.styleScope;
158
+ }
159
+ /**
160
+ * The stylesheet of a drawing with these options, for a page that draws the same bracket twice (a desk's size and a phone's): put it in the page once, give each drawing the same `styleScope`
161
+ * and `style: false`, and every drawing takes its colours from it. Needs `styleScope`; `theme`, `look`, `background`, `layout`, `headers`, and whether there is an `href` or a `highlightPath` decide which rules are in it.
162
+ */
163
+ export function bracketStyle(options) {
164
+ if (options.styleScope === undefined)
165
+ throw new RangeError("taisen-brackets: bracketStyle needs a styleScope");
166
+ const scope = scopeOf(options, "");
167
+ const theme = options.theme ?? "auto";
168
+ if (theme === "none")
169
+ return "";
170
+ const picture = resolveBackground(options.background, theme, 100, 100, "x");
171
+ return styleFor(`.${scope}`, theme, { road: options.look === "road", links: typeof options.href === "function", follow: options.highlightPath !== undefined, bgAuto: picture?.auto, layout: (options.layout ?? "tree") !== "tree" || options.headers === true }, picture?.inks);
172
+ }
173
+ /** The groups' names with the east first, for the series card: the names the teams carry, ordered so that a name with "east" comes before one with "west". */
174
+ function eastFirst(input) {
175
+ const teams = Array.isArray(input) ? [] : input.teams ?? [];
176
+ const names = [...new Set(teams.map((t) => t.group).filter((g) => typeof g === "string"))];
177
+ if (names.length < 2)
178
+ return undefined;
179
+ const rank = (g) => (/east/i.test(g) ? 0 : /west/i.test(g) ? 2 : 1);
180
+ return names.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1));
181
+ }
130
182
  // ---------------------------------------------------------------------------------------------- the plan
131
- function planBracket(layout, o, v, size, vertical, ctx, road, path) {
183
+ function planBracket(layout, o, v, size, vertical, ctx, road, path, kind) {
184
+ // The layouts that draw each tie their own way take the tree's places and lines, and fill the boxes themselves.
185
+ const V = VARIANTS.includes(kind) ? kind : null;
132
186
  const pad = vertical ? 14 : 22;
133
187
  const showLabels = o.roundLabels ?? true;
134
188
  const poster = o.theme === "poster";
@@ -144,6 +198,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
144
198
  const W0 = size.width;
145
199
  const titleH = (o.title ? (poster ? 46 : 38) : 0) + (o.subtitle ? 24 : 0) + (o.title || o.subtitle ? 10 : 0);
146
200
  const hasGroups = layout.sides.some((s) => s.label);
201
+ const groupRoom = o.headers === true ? 34 : 28;
147
202
  const champ = (o.champion ?? true) && layout.champion !== null && layout.finalIndex !== null;
148
203
  const withThird = layout.third !== null && (o.thirdPlace ?? "below") !== "hide";
149
204
  // Beside the final in a horizontal bracket of two halves, under the final; elsewhere the lines into the final would run through it, so it goes at the foot.
@@ -180,7 +235,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
180
235
  // The names of the rounds head their columns: a name that cannot be one line in its column is set on two, which needs a taller strip over the boxes.
181
236
  if (showLabels && layout.columns.some((c) => !nameFits(v, road, roundLabel(layout, c, o.roundNames, v), D + gapT * 0.8)))
182
237
  labelRoom = 34;
183
- const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
238
+ const top = pad + titleH + (hasGroups ? groupRoom : 0) + labelRoom;
184
239
  others = othersOf(layout);
185
240
  ow = clamp(D * 1.1, 120, 200);
186
241
  extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
@@ -204,7 +259,8 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
204
259
  const longest = Math.max(0, ...layout.teams.map((t) => wide(t.name, 13)));
205
260
  const longestAbbr = Math.max(0, ...layout.teams.map((t) => wide(abbrOf(t, v), 13)));
206
261
  // 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) !== "");
262
+ const seedsDefault = V === "rows" || V === "update" || V === "series" || V === "columns";
263
+ const tagsOn = (road || (V !== null && V !== "columns")) && (o.seeds ?? seedsDefault) && layout.ties.some((t) => tagOf(t, 0) !== "" || tagOf(t, 1) !== "");
208
264
  const tagW = tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 8))) + 8 : 0;
209
265
  // The road look's marks and scores are large: they are scaled down together until a row fits its box, before a name is dropped for its abbreviation.
210
266
  const anyNotes = layout.ties.some((t) => noteParts(t, v).length > 0);
@@ -263,8 +319,28 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
263
319
  fitNow = Math.min(1, fitOf(w, chosen.h, chosen.detail));
264
320
  return chosen;
265
321
  };
322
+ // The layouts of their own: how much a box says follows from its width and height; a tall page gives each a height of its own.
323
+ const variantDetail = (w) => {
324
+ if (o.detail && o.detail !== "auto")
325
+ return o.detail;
326
+ if (V === "shields")
327
+ return "mark";
328
+ if (V === "update" || V === "series")
329
+ return w >= 56 ? "compact" : "mark";
330
+ // The box's own painter leaves out what does not fit (the tag, then the names' length); the names are asked for where there is room for most of them.
331
+ if (V === "columns")
332
+ return w >= 110 ? "full" : w >= 60 ? "compact" : "mark";
333
+ return w >= 150 ? "full" : w >= 84 ? "compact" : "mark";
334
+ };
335
+ const oneLine = V === "series" && vertical && layout.ties.every((t) => seriesLineWidth(t, v, 10, o.scores ?? true) <= boxW - 8);
336
+ const variantH = V === "rows" ? 54 : V === "shields" ? 76 : V === "update" ? 46 : V === "columns" ? 84 : oneLine ? 32 : 46;
266
337
  let detail;
267
- if (road) {
338
+ if (V !== null) {
339
+ detail = variantDetail(boxW);
340
+ if (vertical)
341
+ boxH = variantH;
342
+ }
343
+ else if (road) {
268
344
  const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH, vertical);
269
345
  detail = picked.detail;
270
346
  if (vertical)
@@ -291,7 +367,53 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
291
367
  const champH = champ ? trophyH + (hasTrophy ? 6 : 0) + champMark + 8 + nameFont + 6 + 11 + 2 : 0;
292
368
  const finalW = halves ? (vertical ? Math.min(size.width - 2 * pad, Math.max(boxW, 160)) : Math.min(centreT - 12, Math.max(D * 1.1, 120))) : D;
293
369
  let finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
370
+ // The final of the columns layout is two tall slots side by side.
371
+ if (V === "columns")
372
+ finalH = vertical ? Math.max(boxH, 96) : Math.min(Math.max(boxH * 1.7, 120), 220);
294
373
  const thirdH = withThird && !bottomThird ? 18 + boxH + 14 : 0;
374
+ // A final alone (the World Cups of 1974 and 1978 ended with one and a third-place match): there is no tree to join, so it is centred, the champion over it and the third place under it.
375
+ const lone = !halves && layout.sides.length === 1 && layout.sides[0].boxes.length === 1 && layout.finalIndex !== null && layout.sides[0].boxes[0].i === layout.finalIndex;
376
+ if (lone) {
377
+ const availW = W0 - 2 * pad;
378
+ const wl = vertical ? Math.min(availW, road ? 340 : 300) : clamp(availW * 0.3, 240, 380);
379
+ let hl = V === "columns" ? 120 : V === "shields" ? 86 : V !== null ? 72 : road ? 0 : 92;
380
+ let dl;
381
+ if (V !== null)
382
+ dl = variantDetail(wl);
383
+ else if (road) {
384
+ const picked = roadPick(wl, 132);
385
+ dl = picked.detail;
386
+ hl = picked.h;
387
+ }
388
+ else
389
+ dl = pick(wl, hl);
390
+ const strays = othersOf(layout);
391
+ const ows = Math.min(availW, 200);
392
+ const strayH = othersHeight(strays.length, W0, pad, ows, OTHER_H);
393
+ const labelH = showLabels ? 22 : 8;
394
+ const content = (champ ? champH + 14 : 0) + labelH + hl + (withThird ? 8 + 18 + hl + 8 : 0) + strayH;
395
+ const top0 = pad + titleH;
396
+ const height = Math.max(size.height ?? 0, top0 + content + pad);
397
+ const cx = W0 / 2;
398
+ let y = top0 + Math.max(6, (height - top0 - pad - content) / 2);
399
+ if (champ) {
400
+ extras.push(...championBlock(layout.champion, cx, y, champMark, trophyH, nameFont, o, v, ctx, poster));
401
+ y += champH + 14;
402
+ }
403
+ if (showLabels)
404
+ texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), cx, y + 12, "middle"));
405
+ y += labelH;
406
+ boxes.push({ rect: { x: cx - wl / 2, y, w: wl, h: hl }, tie: layout.ties[layout.finalIndex], order: layout.sides[0].boxes[0].order, bye: null, byes: [], final: true, champion: champ });
407
+ y += hl;
408
+ if (withThird) {
409
+ thirdAtFoot(layout, y + 8, cx, wl, hl, v, road, boxes, texts);
410
+ y += 8 + 18 + hl + 8;
411
+ }
412
+ if (strays.length > 0)
413
+ othersBlock(layout, strays, y + 6, W0, pad, ows, OTHER_H, v, road, boxes, texts);
414
+ texts.push(...headings(v, o, W0, pad, poster));
415
+ return finish({ width: W0, height, boxes, links: [], texts, extras, detail: dl, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn, variant: V, em: { full: longest / 13, abbr: longestAbbr / 13, tag: tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 1))) : 0 } });
416
+ }
295
417
  // ----- the slot positions along t
296
418
  if (!vertical) {
297
419
  const sizes = [];
@@ -303,7 +425,7 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
303
425
  sizes.forEach((sz) => { starts.push(t); t += sz + gapT; });
304
426
  const width = Math.max(W0, total + 2 * pad);
305
427
  // ----- span positions
306
- const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
428
+ const top = pad + titleH + (hasGroups ? groupRoom : 0) + labelRoom;
307
429
  const treeH = rows * rowStep;
308
430
  // The final sits at the middle of the rows; the champion above it and the third place below need room.
309
431
  const upNeed = (champ ? champH + 8 : 0) + 22 + finalH / 2;
@@ -354,6 +476,8 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
354
476
  extras.push(...championBlock(layout.champion, cx, fy - champH / 2, champMark, trophyH, nameFont, o, v, ctx, poster));
355
477
  }
356
478
  }
479
+ if (V === "columns")
480
+ growColumns(layout, rects, false, S, treeH, top, height - pad - extraH0);
357
481
  if (showLabels) {
358
482
  for (const c of layout.columns) {
359
483
  const name = roundLabel(layout, c, o.roundNames, v);
@@ -370,13 +494,17 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
370
494
  return;
371
495
  const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
372
496
  const x0 = Math.min(...ks.map((k) => starts[k])), x1 = Math.max(...ks.map((k) => starts[k] + D));
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>`);
497
+ const labelY = top - (showLabels ? labelRoom + 12 : 14);
498
+ if (o.headers === true)
499
+ texts.push(headTile(poster ? v.upper(sd.label) : sd.label, (x0 + x1) / 2, x1 - x0, labelY, sd.key === "l" ? 0 : 1, layout.sides.length === 2));
500
+ else
501
+ texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(labelY)}" font-size="${poster ? 16 : 15}" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(poster ? v.upper(sd.label) : sd.label)}</text>`);
374
502
  });
375
503
  }
376
504
  // lines
377
505
  for (const t of layout.ties) {
378
506
  const p = layout.parent[t.index];
379
- if (p === -1)
507
+ if (p === -1 || V === "shields")
380
508
  continue;
381
509
  const a = rects.get(t.index), b = rects.get(p);
382
510
  if (!a || !b || layout.place[t.index] === "o")
@@ -392,10 +520,10 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
392
520
  thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, road, boxes, texts);
393
521
  othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, road, boxes, texts);
394
522
  texts.push(...headings(v, o, width, pad, poster));
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 });
523
+ return finish({ width, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn, variant: V, em: { full: longest / 13, abbr: longestAbbr / 13, tag: tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 1))) : 0 } });
396
524
  }
397
525
  // ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
398
- const groupBand = hasGroups ? 26 : 0;
526
+ const groupBand = hasGroups ? (o.headers === true ? 34 : 26) : 0;
399
527
  // On a tall page the champion sits at the top, as the page's first thing; the lines into the final must not run through it.
400
528
  const champTop = halves && champ ? champH + 14 : 0;
401
529
  const topT = pad + titleH + champTop + groupBand + (showLabels ? 14 : 0);
@@ -406,14 +534,14 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
406
534
  const othersV = othersHeight(others.length, W0, pad, ow, OTHER_H);
407
535
  // The height the page needs for boxes `h` high with `gap` between the rounds, and what it keeps for the final's band and the third-place match.
408
536
  const needOf = (h, gap) => {
409
- const finalHere = detail === "mark" ? Math.max(h, 52) : h;
537
+ const finalHere = V === "columns" ? Math.max(h, finalH) : detail === "mark" ? Math.max(h, 52) : h;
410
538
  const centre = (showLabels ? 32 : 0) + 6 + finalHere + 14;
411
539
  const thirdV = bottomThird ? 18 + (road ? h : 52) + 16 : 0;
412
540
  const band = h + gap;
413
541
  return (halves ? topT + bandsPerSide * band + centre + bandsPerSide * band + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * band + (champ ? champH : 0) + pad) + othersV + thirdV;
414
542
  };
415
543
  // The road look fits a tall page: the boxes are made lower until the rounds fit, or, with room to spare, the room left over is shared between the rounds.
416
- if (road && size.height !== null) {
544
+ if (road && size.height !== null && V === null) {
417
545
  while (boxH > 40 && needOf(boxH, gapT) > size.height)
418
546
  boxH -= 2;
419
547
  const slack = size.height - needOf(boxH, gapT);
@@ -422,6 +550,21 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
422
550
  D = boxH;
423
551
  finalH = detail === "mark" ? Math.max(boxH, 52) : boxH;
424
552
  }
553
+ // The layouts of their own fill a tall page too: the boxes are made taller, and the rounds are spaced apart, until the page is as high as it was asked to be.
554
+ if (V !== null && size.height !== null && vertical) {
555
+ const cap = V === "series" ? (oneLine ? 52 : 60) : V === "columns" ? 150 : V === "shields" ? 110 : V === "rows" ? 96 : 80;
556
+ while (boxH < cap && needOf(boxH + 2, gapT) <= size.height) {
557
+ boxH += 2;
558
+ if (V !== "columns")
559
+ finalH = boxH;
560
+ }
561
+ D = boxH;
562
+ const slack = size.height - needOf(boxH, gapT);
563
+ if (slack > 0 && bandCount > 0)
564
+ gapT += Math.min(slack / bandCount, 56);
565
+ if (V !== "columns")
566
+ finalH = boxH;
567
+ }
425
568
  const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
426
569
  const thirdH2 = road ? boxH : 52;
427
570
  const thirdBlockV = bottomThird ? 18 + thirdH2 + 16 : 0;
@@ -467,6 +610,8 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
467
610
  const fr = rects.get(layout.finalIndex);
468
611
  extras.push(...championBlock(layout.champion, fr.x + fr.w / 2, fr.y + fr.h + 12, champMark, trophyH, nameFont, o, v, ctx, poster));
469
612
  }
613
+ if (V === "columns")
614
+ growColumns(layout, rects, true, S, treeW, pad, W0 - pad);
470
615
  if (showLabels) {
471
616
  layout.columns.forEach((c, ci) => {
472
617
  const name = roundLabel(layout, c, o.roundNames, v);
@@ -496,12 +641,15 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
496
641
  if (!sd.label)
497
642
  return;
498
643
  const y = sd.key === "l" ? pad + titleH + champTop + 16 : totalT - pad - 2;
499
- texts.push(`<text class="tb-half" x="${num(midX)}" y="${num(y)}" font-size="14" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(v.upper(sd.label))}</text>`);
644
+ if (o.headers === true)
645
+ texts.push(headTile(v.upper(sd.label), midX, W0 - 2 * pad, y, sd.key === "l" ? 0 : 1, layout.sides.length === 2));
646
+ else
647
+ texts.push(`<text class="tb-half" x="${num(midX)}" y="${num(y)}" font-size="14" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(v.upper(sd.label))}</text>`);
500
648
  });
501
649
  }
502
650
  for (const t of layout.ties) {
503
651
  const p = layout.parent[t.index];
504
- if (p === -1)
652
+ if (p === -1 || V === "shields")
505
653
  continue;
506
654
  const a = rects.get(t.index), b = rects.get(p);
507
655
  if (!a || !b || layout.place[t.index] === "o")
@@ -518,7 +666,39 @@ function planBracket(layout, o, v, size, vertical, ctx, road, path) {
518
666
  thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, road ? Math.max(boxW, 200) : 200), thirdH2, v, road, boxes, texts);
519
667
  othersBlock(layout, others, totalT - pad + thirdBlockV + 6, W0, pad, ow, OTHER_H, v, road, boxes, texts);
520
668
  texts.push(...headings(v, o, W0, pad, poster));
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 });
669
+ return finish({ width: W0, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn, variant: V, em: { full: longest / 13, abbr: longestAbbr / 13, tag: tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 1))) : 0 } });
670
+ }
671
+ /**
672
+ * The columns layout's tall slots: a tie of a later round is made taller than the first round's, as tall as the room between its neighbours of the same column leaves (`base` times 1.55 for each round in, at most),
673
+ * on the same centre. `span` is the room across the tree, for a column with a single tie.
674
+ */
675
+ function growColumns(layout, rects, vertical, base, span, lo, hi) {
676
+ const centre = (r) => (vertical ? r.x + r.w / 2 : r.y + r.h / 2);
677
+ for (const sd of layout.sides) {
678
+ layout.columns.forEach((col, ci) => {
679
+ const here = sd.boxes.filter((b) => b.round === col).map((b) => rects.get(b.i)).filter((r) => r !== undefined);
680
+ if (here.length === 0)
681
+ return;
682
+ const at = here.map(centre).sort((a, b) => a - b);
683
+ let gap = Infinity;
684
+ for (let i = 1; i < at.length; i++)
685
+ gap = Math.min(gap, at[i] - at[i - 1]);
686
+ const want = Math.max(base, Math.min(base * 1.55 ** ci, gap === Infinity ? span * 0.6 : gap - 8));
687
+ for (const r of here) {
688
+ const c = centre(r);
689
+ // Never past the room there is on either side of the centre.
690
+ const size = Math.max(Math.min(base, 2 * (c - lo), 2 * (hi - c)), Math.min(want, 2 * (c - lo), 2 * (hi - c)));
691
+ if (vertical) {
692
+ r.x = c - size / 2;
693
+ r.w = size;
694
+ }
695
+ else {
696
+ r.y = c - size / 2;
697
+ r.h = size;
698
+ }
699
+ }
700
+ });
701
+ }
522
702
  }
523
703
  /** A line's class: `tb-path` when both ties it joins are on the highlighted team's path. */
524
704
  function lineClass(path, from, to) {
@@ -675,7 +855,7 @@ function championBlock(team, cx, top, mark, trophyH, nameFont, o, v, ctx, poster
675
855
  }
676
856
  /** The ties that are not part of the tree (they lead nowhere near the final, or are round-robin games), drawn in a grid below it. */
677
857
  function othersOf(layout) {
678
- return [...layout.other, ...layout.robin];
858
+ return [...layout.other, ...layout.robin, ...layout.playIn];
679
859
  }
680
860
  function othersHeight(count, width, pad, ow, oh) {
681
861
  if (!count)
@@ -706,7 +886,7 @@ function hrefOf(o, tie, side) {
706
886
  const url = side === undefined ? o.href(tie) : o.href(tie, side);
707
887
  return typeof url === "string" && safeHref(url) ? url.trim() : null;
708
888
  }
709
- function drawBox(p, plan, o, v, ctx, linked) {
889
+ function drawBox(p, plan, o, v, ctx, linked, side) {
710
890
  const { rect, tie } = p;
711
891
  const { x, y, w, h } = rect;
712
892
  const detail = plan.detail;
@@ -738,7 +918,14 @@ function drawBox(p, plan, o, v, ctx, linked) {
738
918
  return inner;
739
919
  return anchor(url, `${team.name}: ${description}`, `<rect class="tb-hit" x="${num(rx)}" y="${num(ry)}" width="${num(rw)}" height="${num(rh)}" fill="transparent"/>${inner}`);
740
920
  };
741
- if (detail === "mark" && plan.markCols) {
921
+ if (plan.variant !== null) {
922
+ const env = {
923
+ kind: plan.variant, x, y, w, h, tie, order: p.order, side, final: p.final, road, detail, vertical: plan.vertical, tags: plan.tags, codes: plan.codes, scoresOn, v, ctx, o,
924
+ cls, link: row, tagOf, em: plan.em,
925
+ };
926
+ parts.push(...paintVariant(env));
927
+ }
928
+ else if (detail === "mark" && plan.markCols) {
742
929
  // Two columns, each a stack: the seed tag, the mark, its three letters, the score. The marks are as large as the box lets the whole stack be, and every part is inside the box.
743
930
  const padX = 6, padY = 5;
744
931
  const colW = (w - 2 * padX) / 2;
@@ -876,7 +1063,9 @@ function drawBox(p, plan, o, v, ctx, linked) {
876
1063
  }
877
1064
  }
878
1065
  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}`;
1066
+ // The layouts of their own colour a half by its side: the first half (the left, or the top) is `tb-c0`, the second `tb-c1`.
1067
+ const conf = plan.variant !== null && (side === "l" || side === "r") ? ` tb-c${side === "l" ? 0 : 1}` : "";
1068
+ const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}${conf}${extra}`;
880
1069
  const body = boxHref !== null ? anchor(boxHref, description, parts.join("")) : parts.join("");
881
1070
  return `<g class="${cls2}"><title>${esc(description)}</title>${body}</g>`;
882
1071
  }
package/dist/seeding.js CHANGED
@@ -80,10 +80,12 @@ export function advanceWinners(matchups) {
80
80
  m.feeds.forEach((feed, k) => {
81
81
  if (feed == null)
82
82
  return;
83
- const child = at.get(feed);
83
+ const child = at.get(typeof feed === "string" ? feed : feed.tie);
84
84
  if (!child || child.winner === null)
85
85
  return;
86
- const team = child.sides[m.third ? (1 - child.winner) : child.winner];
86
+ // The loser goes on from a third-place match's feeders, and from a feed that asks for the loser (a play-in's).
87
+ const takesLoser = m.third || (typeof feed !== "string" && feed.result === "loser");
88
+ const team = child.sides[takesLoser ? (1 - child.winner) : child.winner];
87
89
  const here = sides[k];
88
90
  if (team && (here !== null && typeof here === "object" ? here.id : here) !== team.id) {
89
91
  sides[k] = team.id;
package/dist/table.js CHANGED
@@ -15,7 +15,7 @@ export function bracketTable(input, options = {}) {
15
15
  const w = v.words;
16
16
  const caption = options.caption ?? fill(layout.rounds === 1 ? w.summaryOne : w.summary, { n: v.number(layout.teams.length), r: v.number(layout.rounds) });
17
17
  const order = layout.ties
18
- .filter((t) => t.round > 0 || layout.robin.includes(t.index))
18
+ .filter((t) => t.round > 0 || layout.robin.includes(t.index) || layout.playIn.includes(t.index))
19
19
  .sort((a, b) => Number(a.third) - Number(b.third) || a.round - b.round || (a.date < b.date ? -1 : a.date > b.date ? 1 : a.index - b.index));
20
20
  const dated = order.some((t) => v.date(t.date) !== null);
21
21
  const name = (t, k) => {
@@ -24,10 +24,13 @@ export function bracketTable(input, options = {}) {
24
24
  return t.winner === k ? `<strong>${text}</strong>` : text;
25
25
  };
26
26
  const score = (t) => (t.result.score ? fill(w.scoreText, { a: v.number(t.result.score[0]), b: v.number(t.result.score[1]) }) : "");
27
+ // Where a side comes from, in words, when the tie says so: "Loser of A v B".
28
+ const named = (i) => fill(w.tieVs, { a: layout.ties[i].sides[0]?.name ?? w.tbd, b: layout.ties[i].sides[1]?.name ?? w.tbd });
29
+ const origins = (t) => (t.from ?? []).flatMap((s) => (s ? [fill(s.result === "loser" ? w.loserOf : w.winnerOf, { name: named(s.tie) })] : []));
27
30
  const rows = order.map((t) => {
28
- const roundText = t.third ? w.thirdPlace : t.round === 0 ? "" : roundLabel(layout, t.round, options.roundNames, v);
31
+ const roundText = t.third ? w.thirdPlace : t.playIn ? w.playIn : t.round === 0 ? "" : roundLabel(layout, t.round, options.roundNames, v);
29
32
  const date = dated ? `<td>${esc(v.date(t.date) ?? "")}</td>` : "";
30
- return `<tr><th scope="row">${esc(roundText)}</th>${date}<td>${name(t, 0)}</td><td>${esc(score(t))}</td><td>${name(t, 1)}</td><td>${esc(noteLong(t, v).join(w.noteJoin))}</td></tr>`;
33
+ return `<tr><th scope="row">${esc(roundText)}</th>${date}<td>${name(t, 0)}</td><td>${esc(score(t))}</td><td>${name(t, 1)}</td><td>${esc([...noteLong(t, v), ...origins(t)].join(w.noteJoin))}</td></tr>`;
31
34
  });
32
35
  return [
33
36
  `<table class="${esc(options.className ?? "tb-table")}" lang="${esc(v.locale.code)}">`,
package/dist/theme.d.ts CHANGED
@@ -34,7 +34,11 @@ export interface StyleParts {
34
34
  follow?: boolean;
35
35
  /** A background with a light and a dark picture in one drawing: the one that does not fit the look is hidden. */
36
36
  bgAuto?: boolean;
37
+ /** The rules of the layouts of their own (slots, shields, conference colours, header tiles, arrows, group cards). */
38
+ layout?: boolean;
37
39
  }
40
+ /** The colours of the two halves (conferences) of the layouts that colour them, each with white letters on it: `--tb-conf-a` and `--tb-conf-b`. Both read 5 to 1 or more against white. */
41
+ export declare const CONFERENCE_COLORS: readonly [string, string];
38
42
  /** The colours a background says to draw in (`backgroundSvg`'s `ink`): what `styleFor` takes in place of a look's own palette. */
39
43
  export interface BackgroundInks {
40
44
  light?: BackgroundInk;
package/dist/theme.js CHANGED
@@ -10,6 +10,8 @@ export const ROAD_THEMES = Object.freeze({
10
10
  poster: { ring: "#f6f1e3", edge: "rgba(0,0,0,.5)", panel: "#1b2766", spine: "#d9b24c" },
11
11
  });
12
12
  const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Hiragino Sans", "Yu Gothic UI", "Noto Sans CJK JP", Meiryo, sans-serif';
13
+ /** The colours of the two halves (conferences) of the layouts that colour them, each with white letters on it: `--tb-conf-a` and `--tb-conf-b`. Both read 5 to 1 or more against white. */
14
+ export const CONFERENCE_COLORS = Object.freeze(["#2457c5", "#b3261e"]);
13
15
  /** A palette and a road palette from a background's ink. */
14
16
  function fromInk(ink) {
15
17
  return [
@@ -53,6 +55,10 @@ function rules(p, r, poster, withFont, parts, side) {
53
55
  if (parts.chart === true) {
54
56
  list.push([[".tb-r-t"], `fill:${v("ink")};font-weight:800`], [[".tb-r-s", ".tb-r-e", ".tb-r-sub", ".tb-r-code", ".tb-r-vs", ".tb-r-dash"], `fill:${v("muted")};font-weight:700`], [[".tb-r-name", ".tb-r-n"], `fill:${v("ink")};font-weight:800`], [[".tb-r-n.tb-r-low"], `fill:${v("muted")}`], [[".tb-r-dim"], "opacity:.5"], [["text"], "font-variant-numeric:tabular-nums"]);
55
57
  }
58
+ if (parts.layout === true) {
59
+ const [ca, cb] = CONFERENCE_COLORS;
60
+ list.push([[".tb-slot"], `fill:${v("plain")};fill-opacity:.55;stroke:${v("edge")};stroke-width:1`], [[".tb-won .tb-slot"], `fill-opacity:.9;stroke:${v("win")};stroke-width:1.6`], [[".tb-lost .tb-slot"], "fill-opacity:.35"], [[".tb-emptybar"], `fill:${v("plain")};opacity:.8`], [[".tb-seed"], "font-variant-numeric:tabular-nums"], [[".tb-seedn"], `fill:${v("muted")};opacity:.85`], [[".tb-shield"], `fill:none;stroke:${v("edge")};stroke-width:1.4`], [[".tb-won .tb-shield"], `stroke:${v("win")};stroke-width:2.2`], [[".tb-shield.tb-empty"], `fill:${v("plain")};fill-opacity:.5`], [[".tb-vs", ".tb-code"], `fill:${v("muted")};font-weight:700`], [[".tb-c0 .tb-box"], `stroke:var(--tb-conf-a, ${ca});stroke-width:1.6`], [[".tb-c1 .tb-box"], `stroke:var(--tb-conf-b, ${cb});stroke-width:1.6`], [[".tb-atag"], `fill:${v("ink")}`], [[".tb-atag-t"], `fill:${v("bg")}`], [[".tb-c0 .tb-atag"], `fill:var(--tb-conf-a, ${ca})`], [[".tb-c1 .tb-atag"], `fill:var(--tb-conf-b, ${cb})`], [[".tb-c0 .tb-atag-t", ".tb-c1 .tb-atag-t"], "fill:#fff"], [[".tb-head"], `fill:${rv("panel", "panel")}`], [[".tb-head.tb-c0"], `fill:var(--tb-conf-a, ${ca})`], [[".tb-head.tb-c1"], `fill:var(--tb-conf-b, ${cb})`], [[".tb-head-t"], `fill:${v("ink")};font-weight:800`], [[".tb-head.tb-c0 + .tb-head-t", ".tb-head.tb-c1 + .tb-head-t"], "fill:#fff"], [[".tb-arrow"], `stroke:${v("line")};stroke-width:1.6;fill:none;stroke-linecap:round;stroke-linejoin:round`], [[".tb-arrow-loser"], "stroke-dasharray:5 4"], [[".tb-arrowhead"], `fill:${v("line")};stroke:none`], [[".tb-gcard"], `fill:${v("box")};stroke:${v("edge")};stroke-width:1`], [[".tb-gtile"], `fill:var(--tb-conf-a, ${ca})`], [[".tb-gtile-t"], "fill:#fff;font-weight:800"], [[".tb-ghead"], `fill:${v("muted")};font-weight:700`], [[".tb-gcell"], `fill:${v("ink")};font-variant-numeric:tabular-nums`], [[".tb-gcell-p"], "font-weight:800"], [[".tb-gname"], `fill:${v("ink")};font-weight:700`], [[".tb-qual"], `fill:${v("win")};fill-opacity:.16`], [[".tb-gsep"], `stroke:${v("edge")};stroke-width:1;stroke-opacity:.6`]);
61
+ }
56
62
  if (parts.follow === true) {
57
63
  list.push([[".tb-tie"], "opacity:.4"], [[".tb-tie.tb-on-path"], "opacity:1"], [[".tb-on-path .tb-box"], `stroke:${v("win")};stroke-width:2.2`], [[".tb-line"], "stroke-opacity:.25"], [[".tb-line.tb-path"], `stroke:${v("win")};stroke-width:4.5;stroke-opacity:1;stroke-linecap:round;stroke-dasharray:.1 9`]);
58
64
  }