@johnmorrisdotca/taisen-brackets 0.0.0-stage → 0.1.1

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/layout.js ADDED
@@ -0,0 +1,261 @@
1
+ import { collectTeams, resolveMatchup } from "./matchup.js";
2
+ /**
3
+ * The shape of a knockout, worked out from its ties: which tie feeds which (a winner goes on to the next tie its team
4
+ * plays, or the tie that `feeds` names), the rounds, which half each tie sits in, and which side has a bye. A tie is
5
+ * a game, a series, two legs or a replayed game, so one layout draws a hockey playoff and a World Cup. Pure and
6
+ * sport-agnostic: no DOM, no drawing.
7
+ */
8
+ export function layoutBracket(input, options = {}) {
9
+ const matchups = Array.isArray(input) ? input : input.matchups;
10
+ const given = Array.isArray(input) ? [] : input.teams ?? [];
11
+ const teamMap = collectTeams(matchups, given);
12
+ const ties = matchups.map((m, i) => resolveMatchup(m, i, teamMap));
13
+ const n = ties.length;
14
+ const byId = new Map();
15
+ for (const t of ties) {
16
+ if (byId.has(t.id))
17
+ throw new Error(`taisen-brackets: two matchups have the id ${JSON.stringify(t.id)}`);
18
+ byId.set(t.id, t.index);
19
+ }
20
+ const thirds = ties.filter((t) => t.third).map((t) => t.index);
21
+ const robin = ties.filter((t) => !t.third && matchups[t.index].round === 0).map((t) => t.index);
22
+ const inTree = ties.filter((t) => !t.third && matchups[t.index].round !== 0).map((t) => t.index);
23
+ const isTree = new Set(inTree);
24
+ const winnerId = (i) => (ties[i].winner === null ? null : ties[i].sides[ties[i].winner]?.id ?? null);
25
+ const sideIdOf = (i, k) => ties[i].sides[k]?.id ?? null;
26
+ // Explicit feeds: the tie named for side k is a child of this one.
27
+ const children = new Map();
28
+ const parent = new Array(n).fill(-1);
29
+ const feedSide = new Map();
30
+ const explicit = new Set();
31
+ for (const j of inTree) {
32
+ const feeds = matchups[j].feeds;
33
+ if (!feeds)
34
+ continue;
35
+ explicit.add(j);
36
+ const list = [];
37
+ feeds.forEach((id, k) => {
38
+ if (id == null)
39
+ return;
40
+ const c = byId.get(id);
41
+ if (c === undefined)
42
+ throw new Error(`taisen-brackets: matchup ${JSON.stringify(ties[j].id)} is fed by ${JSON.stringify(id)}, which is not a matchup`);
43
+ if (!isTree.has(c))
44
+ throw new Error(`taisen-brackets: matchup ${JSON.stringify(ties[j].id)} is fed by ${JSON.stringify(id)}, which is not part of the tree`);
45
+ if (c === j || parent[c] !== -1)
46
+ throw new Error(`taisen-brackets: ${JSON.stringify(id)} feeds more than one matchup, or itself`);
47
+ parent[c] = j;
48
+ feedSide.set(c, k);
49
+ list.push(c);
50
+ });
51
+ children.set(j, list);
52
+ }
53
+ // Rounds: given, or one more than the latest tie that feeds this one.
54
+ const key = (i) => ties[i].date;
55
+ const earlier = (a, b) => (key(a) < key(b) ? true : key(a) > key(b) ? false : a < b);
56
+ const round = new Array(n).fill(-1);
57
+ for (const i of inTree)
58
+ if (matchups[i].round !== undefined && matchups[i].round > 0)
59
+ round[i] = matchups[i].round;
60
+ const memo = new Map();
61
+ const rounding = (j) => {
62
+ if (round[j] > 0)
63
+ return round[j];
64
+ const hit = memo.get(j);
65
+ if (hit !== undefined)
66
+ return hit;
67
+ let feeders;
68
+ if (explicit.has(j))
69
+ feeders = children.get(j) ?? [];
70
+ else {
71
+ const here = [sideIdOf(j, 0), sideIdOf(j, 1)];
72
+ feeders = inTree.filter((i) => i !== j && winnerId(i) !== null && here.includes(winnerId(i)) && earlier(i, j) && !(parent[i] !== -1 && parent[i] !== j));
73
+ }
74
+ const r = 1 + Math.max(0, ...feeders.map(rounding));
75
+ memo.set(j, r);
76
+ return r;
77
+ };
78
+ const raw = new Map();
79
+ for (const i of inTree)
80
+ raw.set(i, rounding(i));
81
+ const distinct = [...new Set(raw.values())].sort((a, b) => a - b);
82
+ for (const i of inTree)
83
+ round[i] = distinct.indexOf(raw.get(i)) + 1;
84
+ const rounds = distinct.length;
85
+ // Derived feeding: a winner goes on to the nearest later tie its team plays, when no `feeds` names the tree.
86
+ for (const i of inTree) {
87
+ if (parent[i] !== -1 || winnerId(i) === null)
88
+ continue;
89
+ let best = -1;
90
+ for (const j of inTree) {
91
+ if (explicit.has(j) || round[j] <= round[i])
92
+ continue;
93
+ if (sideIdOf(j, 0) !== winnerId(i) && sideIdOf(j, 1) !== winnerId(i))
94
+ continue;
95
+ if (best < 0 || round[j] < round[best])
96
+ best = j;
97
+ }
98
+ if (best >= 0) {
99
+ parent[i] = best;
100
+ children.set(best, [...(children.get(best) ?? []), i]);
101
+ feedSide.set(i, sideIdOf(best, 0) === winnerId(i) ? 0 : 1);
102
+ }
103
+ }
104
+ const cmpId = (a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0);
105
+ const first = (a, b) => {
106
+ const na = ties[a].no ?? Infinity, nb = ties[b].no ?? Infinity;
107
+ if (na !== nb)
108
+ return na < nb ? -1 : 1;
109
+ if (ties[a].date !== ties[b].date)
110
+ return ties[a].date < ties[b].date ? -1 : 1;
111
+ return cmpId(sideIdOf(a, 0), sideIdOf(b, 0));
112
+ };
113
+ for (const [p, list] of children)
114
+ if (!explicit.has(p))
115
+ children.set(p, [...list].sort(first));
116
+ const tops = inTree.filter((i) => round[i] === rounds);
117
+ const fin = tops.length === 1 ? tops[0] : null;
118
+ for (const i of thirds)
119
+ ties[i] = { ...ties[i], round: rounds };
120
+ for (const i of inTree)
121
+ ties[i] = { ...ties[i], round: round[i] };
122
+ for (const i of robin)
123
+ ties[i] = { ...ties[i], round: 0 };
124
+ const place = new Array(n).fill("o");
125
+ const subtree = (i) => [i, ...(children.get(i) ?? []).flatMap(subtree)];
126
+ const groupOf = (t) => t?.group ?? null;
127
+ const orderOf = (i) => {
128
+ const ch = children.get(i) ?? [];
129
+ if (ch.length === 2 && feedSide.get(ch[0]) === 1)
130
+ return [1, 0];
131
+ return [0, 1];
132
+ };
133
+ const byeOf = (i) => {
134
+ const ch = children.get(i) ?? [];
135
+ if (round[i] <= 1 || ch.length !== 1)
136
+ return null;
137
+ const fed = feedSide.get(ch[0]);
138
+ if (fed === undefined)
139
+ return null;
140
+ const other = (1 - fed);
141
+ return ties[i].sides[other] ? other : null;
142
+ };
143
+ /** Lays one tree out: leaves take the next rows, a tie sits at the middle of the ones that feed it. */
144
+ const lay = (root, mark) => {
145
+ const boxes = [];
146
+ let next = 0;
147
+ const go = (i) => {
148
+ const ch = children.get(i) ?? [];
149
+ const ys = ch.map(go);
150
+ const y = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : next++;
151
+ boxes.push({ i, round: round[i], y, to: null, fed: ch.length > 0, order: orderOf(i), bye: byeOf(i) });
152
+ place[i] = mark;
153
+ return y;
154
+ };
155
+ go(root);
156
+ return { boxes, rows: Math.max(next, 1) };
157
+ };
158
+ const wantLtr = options.shape === "ltr";
159
+ let sides = [];
160
+ let final = null;
161
+ let shape = "forest";
162
+ if (fin !== null && !wantLtr && (children.get(fin) ?? []).length > 0) {
163
+ shape = "halves";
164
+ place[fin] = "f";
165
+ const ch = children.get(fin);
166
+ let feeders;
167
+ if (explicit.has(fin))
168
+ feeders = [ch[0] ?? null, ch[1] ?? null];
169
+ else
170
+ feeders = [0, 1].map((k) => ch.find((c) => winnerId(c) === sideIdOf(fin, k)) ?? null);
171
+ const spare = ch.filter((c) => !feeders.includes(c));
172
+ feeders = feeders.map((f) => f ?? spare.shift() ?? null);
173
+ if (feeders[0] === null && feeders[1] !== null)
174
+ feeders = [feeders[1], null];
175
+ // A half is a group when at most one team of it comes from another group and fewer than half do.
176
+ const lead = feeders.map((c) => {
177
+ if (c === null)
178
+ return { name: null, clean: false };
179
+ const ts = [...new Set(subtree(c).flatMap((i) => ties[i].sides.map((t) => t?.id).filter((id) => id !== undefined && id !== null)))];
180
+ const counts = new Map();
181
+ for (const id of ts) {
182
+ const g = groupOf(teamMap.get(id) ?? null);
183
+ if (g)
184
+ counts.set(g, (counts.get(g) ?? 0) + 1);
185
+ }
186
+ const top = [...counts].sort((a, b) => b[1] - a[1] || cmpId(a[0], b[0]))[0];
187
+ if (!top)
188
+ return { name: null, clean: false };
189
+ const out = ts.length - top[1];
190
+ return { name: top[0], clean: out <= 1 && out * 2 < ts.length };
191
+ });
192
+ const differ = lead[0].name !== null && lead[1].name !== null && lead[0].name !== lead[1].name;
193
+ const clean = differ && lead[0].clean && lead[1].clean;
194
+ let order = [0, 1];
195
+ if (differ) {
196
+ const preset = options.groupOrder;
197
+ const rank = (g) => {
198
+ if (preset) {
199
+ const at = preset.indexOf(g);
200
+ return at < 0 ? preset.length : at;
201
+ }
202
+ return /west/i.test(g) ? 0 : /east/i.test(g) ? 2 : 1;
203
+ };
204
+ const ra = rank(lead[0].name), rb = rank(lead[1].name);
205
+ if (ra > rb || (ra === rb && lead[0].name > lead[1].name))
206
+ order = [1, 0];
207
+ }
208
+ const built = order
209
+ .map((k) => (feeders[k] === null ? null : { ...lay(feeders[k], "l"), root: feeders[k], label: clean ? lead[k].name : null }))
210
+ .filter((x) => x !== null);
211
+ sides = built.map((x, k) => ({ key: k === 0 ? "l" : "r", label: x.label, boxes: x.boxes, rows: x.rows }));
212
+ built.forEach((x, k) => { if (k === 1)
213
+ for (const b of x.boxes)
214
+ place[b.i] = "r"; });
215
+ const rows = Math.max(1, ...sides.map((s) => s.rows));
216
+ const y = (rows - 1) / 2;
217
+ const leftFeeder = built[0]?.root;
218
+ const finalOrder = leftFeeder !== undefined && feedSide.get(leftFeeder) === 1 ? [1, 0] : [0, 1];
219
+ final = { i: fin, y, order: finalOrder };
220
+ built.forEach((x, k) => { const b = sides[k].boxes.find((q) => q.i === x.root); if (b)
221
+ b.to = y; });
222
+ }
223
+ else if (fin !== null && children.get(fin)?.length) {
224
+ shape = "ltr";
225
+ const l = lay(fin, "l");
226
+ sides = [{ key: "l", label: null, boxes: l.boxes, rows: l.rows }];
227
+ }
228
+ else {
229
+ const roots = inTree.filter((i) => parent[i] === -1).sort((a, b) => round[b] - round[a] || first(a, b));
230
+ const boxes = [];
231
+ let rows = 0;
232
+ for (const r of roots) {
233
+ const l = lay(r, "l");
234
+ boxes.push(...l.boxes.map((b) => ({ ...b, y: b.y + rows })));
235
+ rows += l.rows;
236
+ }
237
+ if (boxes.length)
238
+ sides = [{ key: "l", label: null, boxes, rows }];
239
+ if (fin !== null)
240
+ shape = "ltr";
241
+ }
242
+ for (const s of sides) {
243
+ const ys = new Map(s.boxes.map((b) => [b.i, b.y]));
244
+ for (const b of s.boxes) {
245
+ const p = parent[b.i];
246
+ if (p !== -1 && ys.has(p))
247
+ b.to = ys.get(p);
248
+ }
249
+ }
250
+ if (fin !== null && shape === "halves")
251
+ place[fin] = "f";
252
+ const columns = [...new Set(sides.flatMap((s) => s.boxes.map((b) => b.round)))].filter((r) => shape !== "halves" || fin === null || r !== round[fin]).sort((a, b) => a - b);
253
+ const other = inTree.filter((i) => place[i] === "o");
254
+ const champion = fin !== null && ties[fin].winner !== null ? ties[fin].sides[ties[fin].winner] : null;
255
+ return {
256
+ ties, shape, sides, final, finalIndex: fin, columns, rounds,
257
+ rows: Math.max(1, ...sides.map((s) => s.rows)),
258
+ other, robin, third: thirds[0] ?? null, place, parent, champion,
259
+ teams: [...teamMap.values()],
260
+ };
261
+ }
@@ -0,0 +1,31 @@
1
+ import type { Team } from "./types.ts";
2
+ /**
3
+ * Takes inline SVG markup from a caller and makes it safe to put inside a drawing: the outer `<svg>`'s viewBox is kept,
4
+ * and scripts, event handlers, `<style>`, `<foreignObject>`, comments and `javascript:` addresses are removed. Returns the
5
+ * viewBox and the inner markup, or `null` when it is not an `<svg>`.
6
+ */
7
+ export declare function cleanSvg(markup: string): {
8
+ viewBox: string;
9
+ inner: string;
10
+ } | null;
11
+ /** Whether an image address is one a drawing will load: http(s), a relative path, or a `data:image/`. */
12
+ export declare function safeUrl(url: string): boolean;
13
+ export interface MarkContext {
14
+ /** Collects the radii that need a round clip; the drawing writes `<clipPath>` for each. */
15
+ radii: Set<number>;
16
+ /** Inline SVG marks, each written once as a `<symbol>` and used wherever the team appears. Keyed by the markup. */
17
+ symbols: Map<string, {
18
+ id: string;
19
+ viewBox: string;
20
+ inner: string;
21
+ }>;
22
+ prefix: string;
23
+ shape: "round" | "square" | "none";
24
+ }
25
+ /** The id of the circular clip for a radius. */
26
+ export declare const clipId: (prefix: string, r: number) => string;
27
+ /**
28
+ * A team's mark centred at (cx, cy), `size` across. A logo or flag is clipped to a circle (or left square); a team
29
+ * with no mark, or one that cannot be used, is its initials on a plain disc. Returns SVG text.
30
+ */
31
+ export declare function drawMark(team: Team | null, cx: number, cy: number, size: number, ctx: MarkContext, abbrText?: string): string;
package/dist/marks.js ADDED
@@ -0,0 +1,84 @@
1
+ import { abbreviate, esc, num } from "./text.js";
2
+ /**
3
+ * Takes inline SVG markup from a caller and makes it safe to put inside a drawing: the outer `<svg>`'s viewBox is kept,
4
+ * and scripts, event handlers, `<style>`, `<foreignObject>`, comments and `javascript:` addresses are removed. Returns the
5
+ * viewBox and the inner markup, or `null` when it is not an `<svg>`.
6
+ */
7
+ export function cleanSvg(markup) {
8
+ const text = markup.trim().replace(/^<\?xml[^>]*>/i, "").replace(/<!DOCTYPE[^>]*>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
9
+ const open = /^<svg\b([^>]*)>/i.exec(text);
10
+ if (!open || !/<\/svg>\s*$/i.test(text))
11
+ return null;
12
+ const attrs = open[1];
13
+ const viewBox = /\bviewBox\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1];
14
+ const w = /\bwidth\s*=\s*["']?([\d.]+)/i.exec(attrs)?.[1];
15
+ const h = /\bheight\s*=\s*["']?([\d.]+)/i.exec(attrs)?.[1];
16
+ const box = viewBox ?? (w && h ? `0 0 ${w} ${h}` : "0 0 100 100");
17
+ let inner = text.slice(open[0].length).replace(/<\/svg>\s*$/i, "");
18
+ inner = inner
19
+ .replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
20
+ .replace(/<script\b[^>]*\/>/gi, "")
21
+ .replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
22
+ .replace(/<foreignObject\b[\s\S]*?<\/foreignObject\s*>/gi, "")
23
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
24
+ .replace(/(\s(?:xlink:)?href\s*=\s*)(["'])\s*javascript:[^"']*\2/gi, "$1$2#$2");
25
+ return { viewBox: box, inner };
26
+ }
27
+ /** Readable text on a team colour: white on a dark one, near-black on a light one; `null` when the colour is not a hex one this can read. */
28
+ function inkOn(color) {
29
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())?.[1];
30
+ if (!hex)
31
+ return null;
32
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
33
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
34
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.35 ? "#ffffff" : "#1f2320";
35
+ }
36
+ /** Whether an image address is one a drawing will load: http(s), a relative path, or a `data:image/`. */
37
+ export function safeUrl(url) {
38
+ const u = url.trim();
39
+ if (/^data:image\/(png|jpe?g|gif|webp|svg\+xml|avif);/i.test(u))
40
+ return true;
41
+ if (/^https?:\/\//i.test(u))
42
+ return true;
43
+ return !/^[a-z][a-z0-9+.-]*:/i.test(u) && !u.startsWith("//") ? u.length > 0 : false;
44
+ }
45
+ /** The id of the circular clip for a radius. */
46
+ export const clipId = (prefix, r) => `${prefix}-c${String(num(r)).replace(".", "_")}`;
47
+ /**
48
+ * A team's mark centred at (cx, cy), `size` across. A logo or flag is clipped to a circle (or left square); a team
49
+ * with no mark, or one that cannot be used, is its initials on a plain disc. Returns SVG text.
50
+ */
51
+ export function drawMark(team, cx, cy, size, ctx, abbrText) {
52
+ const r = size / 2;
53
+ if (!team)
54
+ return `<circle class="tb-plain tb-mark" cx="${num(cx)}" cy="${num(cy)}" r="${num(r)}" opacity=".5"/>`;
55
+ const x = cx - r, y = cy - r;
56
+ const clip = ctx.shape === "round";
57
+ if (clip)
58
+ ctx.radii.add(r);
59
+ const wrapOpen = clip ? `<g class="tb-mark" transform="translate(${num(cx)} ${num(cy)})" clip-path="url(#${clipId(ctx.prefix, r)})">` : `<g class="tb-mark">`;
60
+ const ox = clip ? -r : x, oy = clip ? -r : y;
61
+ const ring = clip ? `<circle class="tb-ring" cx="${num(cx)}" cy="${num(cy)}" r="${num(r)}"/>` : `<rect class="tb-ring" x="${num(x)}" y="${num(y)}" width="${num(size)}" height="${num(size)}" rx="2"/>`;
62
+ const mark = team.mark?.trim();
63
+ if (mark) {
64
+ if (mark.startsWith("<")) {
65
+ const svg = cleanSvg(mark);
66
+ if (svg) {
67
+ let symbol = ctx.symbols.get(mark);
68
+ if (!symbol) {
69
+ symbol = { id: `${ctx.prefix}-m${ctx.symbols.size + 1}`, ...svg };
70
+ ctx.symbols.set(mark, symbol);
71
+ }
72
+ return `${wrapOpen}<use href="#${symbol.id}" x="${num(ox)}" y="${num(oy)}" width="${num(size)}" height="${num(size)}"/></g>${ring}`;
73
+ }
74
+ }
75
+ else if (safeUrl(mark)) {
76
+ return `${wrapOpen}<image href="${esc(mark)}" x="${num(ox)}" y="${num(oy)}" width="${num(size)}" height="${num(size)}" preserveAspectRatio="xMidYMid slice"/></g>${ring}`;
77
+ }
78
+ }
79
+ const text = (abbrText ?? team.abbr ?? abbreviate(team.name)).slice(0, 3);
80
+ const fill = team.color ? ` style="fill:${esc(team.color)}"` : "";
81
+ 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}/>`;
82
+ const ink = team.color ? inkOn(team.color) : null;
83
+ return `<g class="tb-mark">${shape}<text class="tb-initials" x="${num(cx)}" y="${num(cy + size * 0.12)}" font-size="${num(size * 0.36)}" font-weight="700" text-anchor="middle"${ink ? ` style="fill:${ink}"` : ""}>${esc(text)}</text></g>${ring}`;
84
+ }
@@ -0,0 +1,11 @@
1
+ import type { Matchup, SideId, Team, Tie, Words } from "./types.ts";
2
+ /** A bracket's teams: the list given, plus a team made from every id that a matchup names and the list does not. */
3
+ export declare function collectTeams(matchups: readonly Matchup[], given?: readonly Team[]): Map<SideId, Team>;
4
+ /** One matchup as a tie: sides resolved to teams, the winner and the result worked out. The round is set by the layout. */
5
+ export declare function resolveMatchup(m: Matchup, index: number, teams: ReadonlyMap<SideId, Team>): Tie;
6
+ /** The short name of a team for a small box. */
7
+ export declare const abbrOf: (team: Team | null, words: Words) => string;
8
+ /** The pieces of a note under a tie's score: "a.e.t.", "pens", "in 7", "agg.". */
9
+ export declare function noteParts(tie: Tie, words: Words): string[];
10
+ /** A tie in a sentence, for a tooltip, a table and a description: "Argentina 3, France 3 (a.e.t., pens 4-2). Winner: Argentina". */
11
+ export declare function describeTie(tie: Tie, words: Words): string;
@@ -0,0 +1,147 @@
1
+ import { abbreviate } from "./text.js";
2
+ import { fill } from "./words.js";
3
+ /** A bracket's teams: the list given, plus a team made from every id that a matchup names and the list does not. */
4
+ export function collectTeams(matchups, given = []) {
5
+ const teams = new Map();
6
+ for (const team of given) {
7
+ if (teams.has(team.id))
8
+ throw new Error(`taisen-brackets: two teams have the id ${JSON.stringify(team.id)}`);
9
+ teams.set(team.id, team);
10
+ }
11
+ for (const m of matchups) {
12
+ for (const side of m.sides) {
13
+ if (side === null || side === undefined)
14
+ continue;
15
+ if (typeof side === "object") {
16
+ if (!teams.has(side.id))
17
+ teams.set(side.id, side);
18
+ }
19
+ else if (!teams.has(side))
20
+ teams.set(side, { id: side, name: String(side) });
21
+ }
22
+ }
23
+ return teams;
24
+ }
25
+ const sideId = (side) => (side === null || side === undefined ? null : typeof side === "object" ? side.id : side);
26
+ /** What the games of a tie come to. `winner` is the side the games settle (0 or 1), or null. */
27
+ function settle(m, kind) {
28
+ const listed = m.games ?? m.legs ?? [];
29
+ const lone = listed.length === 0 && m.score && kind !== "series" && kind !== "aggregate" ? { score: m.score, extraTime: m.extraTime, pens: m.pens } : null;
30
+ const games = lone ? [lone] : listed;
31
+ const bestOf = m.bestOf ?? null;
32
+ const base = { kind, bestOf, extraTime: false, replays: 0, pens: null, games: games.map((g) => g.score) };
33
+ const byScore = (a, b) => (a > b ? 0 : b > a ? 1 : null);
34
+ const byPens = (pens) => (pens ? byScore(pens[0], pens[1]) : null);
35
+ if (kind === "series") {
36
+ let wins = null;
37
+ if (games.length)
38
+ wins = [games.filter((g) => g.score[0] > g.score[1]).length, games.filter((g) => g.score[1] > g.score[0]).length];
39
+ else if (m.score)
40
+ wins = m.score;
41
+ const need = bestOf ? Math.ceil(bestOf / 2) : null;
42
+ const winner = wins && need ? (wins[0] >= need ? 0 : wins[1] >= need ? 1 : null) : null;
43
+ return { result: { ...base, score: wins, played: games.length || (wins ? wins[0] + wins[1] : 0), decided: winner !== null }, winner };
44
+ }
45
+ if (kind === "aggregate") {
46
+ const last = games.at(-1);
47
+ const total = games.length ? [games.reduce((s, g) => s + g.score[0], 0), games.reduce((s, g) => s + g.score[1], 0)] : m.score ?? null;
48
+ const pens = last?.pens ?? m.pens ?? null;
49
+ const winner = total ? byScore(total[0], total[1]) ?? byPens(pens) : null;
50
+ return { result: { ...base, score: total, played: games.length, pens, extraTime: games.some((g) => g.extraTime) || !!m.extraTime, decided: winner !== null }, winner };
51
+ }
52
+ // A game, or a replay: the last game decides.
53
+ const last = games.at(-1);
54
+ if (!last)
55
+ return { result: { ...base, score: null, played: 0, decided: false }, winner: null };
56
+ const winner = byScore(last.score[0], last.score[1]) ?? byPens(last.pens ?? null);
57
+ const replays = kind === "replay" ? games.filter((g) => g.replay).length || Math.max(games.length - 1, 0) : 0;
58
+ return { result: { ...base, score: last.score, played: games.length, pens: last.pens ?? null, extraTime: !!last.extraTime, replays, decided: winner !== null }, winner };
59
+ }
60
+ /** The kind of tie, read from the fields when `kind` is not given. */
61
+ function kindOf(m) {
62
+ if (m.kind)
63
+ return m.kind;
64
+ if (m.legs?.length)
65
+ return "aggregate";
66
+ if ((m.games ?? []).some((g) => g.replay))
67
+ return "replay";
68
+ if (m.bestOf || (m.games?.length ?? 0) > 1)
69
+ return "series";
70
+ return "game";
71
+ }
72
+ /** One matchup as a tie: sides resolved to teams, the winner and the result worked out. The round is set by the layout. */
73
+ export function resolveMatchup(m, index, teams) {
74
+ const ids = [sideId(m.sides[0]), sideId(m.sides[1])];
75
+ if (ids[0] !== null && ids[0] === ids[1])
76
+ throw new Error(`taisen-brackets: matchup ${index} has ${JSON.stringify(ids[0])} on both sides`);
77
+ const kind = kindOf(m);
78
+ const { result, winner: settled } = settle(m, kind);
79
+ let winner = settled;
80
+ if (m.winner !== undefined) {
81
+ if (m.winner === null)
82
+ winner = null;
83
+ else if (ids[0] === m.winner)
84
+ winner = 0;
85
+ else if (ids[1] === m.winner)
86
+ winner = 1;
87
+ else
88
+ throw new Error(`taisen-brackets: matchup ${index}'s winner ${JSON.stringify(m.winner)} is not one of its sides`);
89
+ }
90
+ const sides = ids.map((id) => (id === null ? null : teams.get(id) ?? null));
91
+ return {
92
+ index,
93
+ id: m.id ?? `m${index + 1}`,
94
+ round: m.round ?? -1,
95
+ sides,
96
+ winner,
97
+ result: { ...result, decided: winner !== null },
98
+ date: m.date ?? m.games?.[0]?.date ?? m.legs?.[0]?.date ?? "",
99
+ no: m.no ?? null,
100
+ third: !!m.third,
101
+ note: m.note ?? null,
102
+ };
103
+ }
104
+ /** The short name of a team for a small box. */
105
+ export const abbrOf = (team, words) => (team ? team.abbr ?? abbreviate(team.name) : words.tbd);
106
+ /** The pieces of a note under a tie's score: "a.e.t.", "pens", "in 7", "agg.". */
107
+ export function noteParts(tie, words) {
108
+ const r = tie.result;
109
+ const out = [];
110
+ if (r.kind === "series") {
111
+ if (r.decided && r.played > 0)
112
+ out.push(fill(words.inGames, { n: r.played }));
113
+ else if (r.bestOf)
114
+ out.push(fill(words.bestOf, { n: r.bestOf }));
115
+ }
116
+ else {
117
+ if (r.kind === "aggregate")
118
+ out.push(words.aggregate);
119
+ if (r.kind === "replay")
120
+ out.push(words.replay);
121
+ if (r.extraTime)
122
+ out.push(words.aet);
123
+ if (r.pens)
124
+ out.push(words.pens);
125
+ }
126
+ if (tie.note)
127
+ out.push(tie.note);
128
+ return out;
129
+ }
130
+ /** A tie in a sentence, for a tooltip, a table and a description: "Argentina 3, France 3 (a.e.t., pens 4-2). Winner: Argentina". */
131
+ export function describeTie(tie, words) {
132
+ const name = (t) => (t ? t.name : words.tbd);
133
+ const [a, b] = tie.sides;
134
+ const r = tie.result;
135
+ let text;
136
+ if (!r.score)
137
+ text = `${name(a)} ${words.versus} ${name(b)}`;
138
+ else {
139
+ text = `${name(a)} ${r.score[0]}, ${name(b)} ${r.score[1]}`;
140
+ const extra = noteParts(tie, words).map((part) => (part === words.pens && r.pens ? `${words.pens} ${r.pens[0]}-${r.pens[1]}` : part));
141
+ if (extra.length)
142
+ text += ` (${extra.join(", ")})`;
143
+ }
144
+ if (tie.winner !== null)
145
+ text += `. ${words.winner}: ${name(tie.sides[tie.winner])}`;
146
+ return text;
147
+ }
@@ -0,0 +1,3 @@
1
+ export { playoffFormat, playoffEras, LEAGUES } from "./presets.ts";
2
+ export type { League, PlayoffFormat, PlayoffRound } from "./presets.ts";
3
+ export { roundName, roundNamesFor } from "./rounds.ts";
@@ -0,0 +1,2 @@
1
+ export { playoffFormat, playoffEras, LEAGUES } from "./presets.js";
2
+ export { roundName, roundNamesFor } from "./rounds.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Playoff formats by era: the round names, the length of each round's series and who has a bye, for the five North
3
+ * American leagues and the FIFA World Cup. Data only; nothing here draws. Each era was checked against the English
4
+ * Wikipedia articles in its `sources` (2026-10-13); where a season did something of its own (a strike, a pandemic) it has
5
+ * its own entry. The year is the year the playoffs are played in: the NHL's 2024 is the 2023-24 season.
6
+ */
7
+ export type League = "nhl" | "nba" | "mlb" | "nfl" | "cfl" | "worldcup";
8
+ export interface PlayoffRound {
9
+ /** The round's name as the league calls it. */
10
+ name: string;
11
+ /** The length of the series, or 1 for a single game; null when the round is two legs or a group stage. */
12
+ bestOf: number | null;
13
+ }
14
+ export interface PlayoffFormat {
15
+ league: League;
16
+ /** The season (or edition) this was asked for. */
17
+ year: number;
18
+ /** The first and last year of the era this entry covers; `to` is null while it is current. */
19
+ from: number;
20
+ to: number | null;
21
+ /** The era in a few words. */
22
+ label: string;
23
+ /** The rounds, first round first. */
24
+ rounds: PlayoffRound[];
25
+ /** How many teams start the bracket, or null where it varies. */
26
+ teams: number | null;
27
+ /** The number of teams of each half that skip the first round. */
28
+ byes: number;
29
+ /** The names of the two halves, left first, or null where the bracket is not split in two. */
30
+ groups: readonly [string, string] | null;
31
+ note?: string;
32
+ /** The English Wikipedia articles this entry was checked against (CC BY-SA 4.0; only facts are used). */
33
+ sources: readonly string[];
34
+ }
35
+ /** The leagues that have a format here. */
36
+ export declare const LEAGUES: readonly League[];
37
+ /**
38
+ * The playoff format of a league in a season (or a World Cup in its year), or `null` when there was none or this
39
+ * package does not cover it. The rounds' names go straight to `drawBracket`'s `roundNames`.
40
+ */
41
+ export declare function playoffFormat(league: League, year: number): PlayoffFormat | null;
42
+ /** Every era of a league, oldest first. */
43
+ export declare function playoffEras(league: League): PlayoffFormat[];