@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/CHANGELOG.md +25 -0
- package/NOTICE.md +2 -0
- package/README.md +159 -29
- package/dist/furniture.d.ts +10 -0
- package/dist/furniture.js +29 -0
- package/dist/groups.d.ts +50 -0
- package/dist/groups.js +204 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/layout.js +56 -6
- package/dist/layouts.d.ts +48 -0
- package/dist/layouts.js +388 -0
- package/dist/locales.js +10 -0
- package/dist/marks.d.ts +2 -0
- package/dist/marks.js +1 -0
- package/dist/matchup.js +1 -0
- package/dist/playin.d.ts +8 -0
- package/dist/playin.js +232 -0
- package/dist/render.d.ts +8 -1
- package/dist/render.js +223 -34
- package/dist/seeding.js +4 -2
- package/dist/table.js +6 -3
- package/dist/theme.d.ts +4 -0
- package/dist/theme.js +6 -0
- package/dist/types.d.ts +66 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -3
package/dist/playin.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { fill } from "./locales.js";
|
|
2
|
+
import { abbrOf, describeTie } from "./matchup.js";
|
|
3
|
+
import { drawMark } from "./marks.js";
|
|
4
|
+
import { bold, headTile, headingTexts, titleHeight } from "./furniture.js";
|
|
5
|
+
import { esc, fit, num } from "./text.js";
|
|
6
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
7
|
+
/** Draws the play-in games of each half. */
|
|
8
|
+
export function drawPlayIn(layout, o, v, size, ctx, road, linked) {
|
|
9
|
+
void linked;
|
|
10
|
+
void road;
|
|
11
|
+
const w = v.words;
|
|
12
|
+
const games = layout.playIn;
|
|
13
|
+
if (games.length === 0)
|
|
14
|
+
throw new RangeError('taisen-brackets: the playin layout needs play-in games: matchups with stage: "play-in"');
|
|
15
|
+
const inPlayIn = new Set(games);
|
|
16
|
+
const sources = (i) => (layout.ties[i].from ?? []).flatMap((s, k) => (s ? [{ tie: s.tie, result: s.result, side: k }] : []));
|
|
17
|
+
// The halves: the play-in games joined by what feeds what.
|
|
18
|
+
const parent = new Map(games.map((g) => [g, g]));
|
|
19
|
+
const find = (a) => { let r = a; while (parent.get(r) !== r)
|
|
20
|
+
r = parent.get(r); return r; };
|
|
21
|
+
for (const g of games)
|
|
22
|
+
for (const s of sources(g))
|
|
23
|
+
if (inPlayIn.has(s.tie))
|
|
24
|
+
parent.set(find(g), find(s.tie));
|
|
25
|
+
const groups = new Map();
|
|
26
|
+
for (const g of games)
|
|
27
|
+
groups.set(find(g), [...(groups.get(find(g)) ?? []), g]);
|
|
28
|
+
const groupOf = (list) => {
|
|
29
|
+
const counts = new Map();
|
|
30
|
+
for (const g of list)
|
|
31
|
+
for (const t of layout.ties[g].sides)
|
|
32
|
+
if (t?.group)
|
|
33
|
+
counts.set(t.group, (counts.get(t.group) ?? 0) + 1);
|
|
34
|
+
return [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0]?.[0] ?? null;
|
|
35
|
+
};
|
|
36
|
+
const rank = (g) => (g !== null && /west/i.test(g) ? 0 : g !== null && /east/i.test(g) ? 2 : 1);
|
|
37
|
+
const panels = [...groups.values()].map((list) => {
|
|
38
|
+
const label = groupOf(list);
|
|
39
|
+
const set = new Set(list);
|
|
40
|
+
const slots = layout.ties.filter((t) => !inPlayIn.has(t.index)).flatMap((t) => sources(t.index).filter((s) => set.has(s.tie)).map((s) => ({ to: t, side: s.side, from: s.tie, result: s.result })));
|
|
41
|
+
const inGames = new Set(list.flatMap((g) => layout.ties[g].sides.map((t) => t?.id)));
|
|
42
|
+
const tiles = label === null && groups.size > 1 ? [] : layout.teams.filter((t) => typeof t.seed === "number" && !inGames.has(t.id) && (label === null || t.group === label)).sort((a, b) => a.seed - b.seed);
|
|
43
|
+
return { label, games: list, slots, tiles };
|
|
44
|
+
}).sort((a, b) => rank(a.label) - rank(b.label));
|
|
45
|
+
const W = size.width;
|
|
46
|
+
const pad = W < 500 ? 14 : 22;
|
|
47
|
+
const avail = W - 2 * pad;
|
|
48
|
+
const side = panels.length === 2 && avail >= 900;
|
|
49
|
+
const panelW = side ? (avail - 28) / 2 : avail;
|
|
50
|
+
const titleH = titleHeight(o);
|
|
51
|
+
const tileH = 34, tileGap = 6, gameH = 54, colGap = 24, unit = 44, slotH = 32;
|
|
52
|
+
const wideTiles = panelW >= 470;
|
|
53
|
+
const tileW = 124;
|
|
54
|
+
const body = [];
|
|
55
|
+
const parts = [];
|
|
56
|
+
const heights = [];
|
|
57
|
+
const drawn = panels.map((panel, pi) => {
|
|
58
|
+
// Depth and row of each game: a game with no source in the play-in is at the start; one that takes others sits between them.
|
|
59
|
+
const depth = new Map();
|
|
60
|
+
const pos = new Map();
|
|
61
|
+
let next = 0;
|
|
62
|
+
const walk = (g) => {
|
|
63
|
+
if (depth.has(g))
|
|
64
|
+
return;
|
|
65
|
+
const src = sources(g).filter((s) => inPlayIn.has(s.tie));
|
|
66
|
+
src.forEach((s) => walk(s.tie));
|
|
67
|
+
depth.set(g, src.length === 0 ? 0 : 1 + Math.max(...src.map((s) => depth.get(s.tie))));
|
|
68
|
+
pos.set(g, src.length === 0 ? (next += 2) - 2 : src.reduce((sum, s) => sum + pos.get(s.tie), 0) / src.length);
|
|
69
|
+
};
|
|
70
|
+
[...panel.games].sort((a, b) => a - b).forEach(walk);
|
|
71
|
+
const maxDepth = Math.max(...panel.games.map((g) => depth.get(g)));
|
|
72
|
+
const maxPos = Math.max(...panel.games.map((g) => pos.get(g)));
|
|
73
|
+
const slotW = wideTiles ? 100 : 84;
|
|
74
|
+
const innerW = panelW - (wideTiles && panel.tiles.length > 0 ? tileW + colGap : 0);
|
|
75
|
+
const gameW = clamp((innerW - (panel.slots.length > 0 ? slotW + colGap : 0) - colGap * maxDepth) / (maxDepth + 1), 90, 168);
|
|
76
|
+
const gamesH = maxPos * unit + gameH;
|
|
77
|
+
const tilesH = panel.tiles.length * (tileH + tileGap) - tileGap;
|
|
78
|
+
const gridCols = wideTiles ? 1 : Math.max(1, Math.floor((panelW + 6) / 90));
|
|
79
|
+
const gridH = !wideTiles && panel.tiles.length > 0 ? Math.ceil(panel.tiles.length / gridCols) * (tileH + tileGap) + 10 : 0;
|
|
80
|
+
const head = 42;
|
|
81
|
+
const bodyH = Math.max(wideTiles ? tilesH : 0, gamesH + 18);
|
|
82
|
+
return { panel, pi, depth, pos, maxDepth, maxPos, slotW, innerW, gameW, gamesH, tilesH, gridCols, gridH, head, bodyH, height: head + gridH + bodyH + 8 };
|
|
83
|
+
});
|
|
84
|
+
// Where the panels stand: side by side, or one under the other.
|
|
85
|
+
const content = side ? Math.max(...drawn.map((d) => d.height)) : drawn.reduce((s, d) => s + d.height, 0) + (drawn.length - 1) * 18;
|
|
86
|
+
const top0 = pad + titleH;
|
|
87
|
+
const height = Math.max(size.height ?? 0, top0 + content + pad);
|
|
88
|
+
let yCursor = top0 + Math.max(0, (height - top0 - pad - content) / 2);
|
|
89
|
+
void parts;
|
|
90
|
+
void heights;
|
|
91
|
+
drawn.forEach((d) => {
|
|
92
|
+
const px = side ? pad + d.pi * (panelW + 28) : pad;
|
|
93
|
+
const py = side ? top0 + Math.max(0, (height - top0 - pad - content) / 2) : yCursor;
|
|
94
|
+
if (!side)
|
|
95
|
+
yCursor += d.height + 18;
|
|
96
|
+
const coloured = panels.length === 2;
|
|
97
|
+
if (d.panel.label !== null)
|
|
98
|
+
body.push(headTile(o.theme === "poster" ? v.upper(d.panel.label) : d.panel.label, px + panelW / 2, panelW, py + 18, d.pi === 0 ? 0 : 1, coloured));
|
|
99
|
+
const by = py + d.head + d.gridH;
|
|
100
|
+
// The seeds that need no game: a column of tiles at the left, or a grid over the games.
|
|
101
|
+
const seedTile = (t, rect) => {
|
|
102
|
+
const label = t.seed !== undefined ? v.number(t.seed) : "";
|
|
103
|
+
const md = ctx.shape === "none" ? 0 : Math.min(rect.h - 10, 24);
|
|
104
|
+
const parts2 = [];
|
|
105
|
+
let x = rect.x + 6;
|
|
106
|
+
if (md > 0) {
|
|
107
|
+
parts2.push(drawMark(t, x + md / 2, rect.y + rect.h / 2, md, ctx));
|
|
108
|
+
x += md + 5;
|
|
109
|
+
}
|
|
110
|
+
const nf = 12;
|
|
111
|
+
const sf = label ? 15 : 0;
|
|
112
|
+
const sw = label ? bold(label, sf) + 6 : 0;
|
|
113
|
+
const room = rect.x + rect.w - 6 - sw - x;
|
|
114
|
+
parts2.push(`<text class="tb-name" x="${num(x)}" y="${num(rect.y + rect.h / 2 + nf * 0.35)}" font-size="${nf}">${esc(fit(abbrOf(t, v), nf, Math.max(room, 8), bold))}</text>`);
|
|
115
|
+
if (label)
|
|
116
|
+
parts2.push(`<text class="tb-seedn" x="${num(rect.x + rect.w - 6)}" y="${num(rect.y + rect.h / 2 + sf * 0.35)}" font-size="${sf}" font-weight="800" text-anchor="end">${esc(label)}</text>`);
|
|
117
|
+
return `<g class="tb-group"><title>${esc(`${fill(w.seedSlot, { n: label })}: ${v.team(t)}`)}</title><rect class="tb-box" x="${num(rect.x)}" y="${num(rect.y)}" width="${num(rect.w)}" height="${num(rect.h)}" rx="7"/>${parts2.join("")}</g>`;
|
|
118
|
+
};
|
|
119
|
+
if (wideTiles)
|
|
120
|
+
d.panel.tiles.forEach((t, i) => body.push(seedTile(t, { x: px, y: by + i * (tileH + tileGap), w: tileW, h: tileH })));
|
|
121
|
+
else if (d.gridH > 0) {
|
|
122
|
+
const cell = (panelW - tileGap * (d.gridCols - 1)) / d.gridCols;
|
|
123
|
+
d.panel.tiles.forEach((t, i) => body.push(seedTile(t, { x: px + (i % d.gridCols) * (cell + tileGap), y: py + d.head + Math.floor(i / d.gridCols) * (tileH + tileGap), w: cell, h: tileH })));
|
|
124
|
+
}
|
|
125
|
+
// The games, in columns by depth; the seeds they lead to, in the column after.
|
|
126
|
+
const gx0 = px + (wideTiles && d.panel.tiles.length > 0 ? tileW + colGap : 0);
|
|
127
|
+
const colX = (c) => gx0 + c * (d.gameW + colGap);
|
|
128
|
+
const rects = new Map();
|
|
129
|
+
const cyOf = (g) => by + 18 + gameH / 2 + d.pos.get(g) * unit;
|
|
130
|
+
body.push(`<text class="tb-ghead" x="${num(gx0)}" y="${num(by + 9)}" font-size="10" letter-spacing=".1em">${esc(v.upper(w.playIn))}</text>`);
|
|
131
|
+
const placeholder = (t, k) => {
|
|
132
|
+
const s = t.from?.[k];
|
|
133
|
+
if (!s)
|
|
134
|
+
return w.tbd;
|
|
135
|
+
const src = layout.ties[s.tie];
|
|
136
|
+
const seed = (j) => src.seeds?.[j] ?? src.sides[j]?.seed;
|
|
137
|
+
if (seed(0) !== undefined && seed(0) !== null && seed(1) !== undefined && seed(1) !== null)
|
|
138
|
+
return fill(s.result === "loser" ? w.playInLoser : w.playInWinner, { a: v.number(Number(seed(0))), b: v.number(Number(seed(1))) });
|
|
139
|
+
return fill(s.result === "loser" ? w.loserOf : w.winnerOf, { name: fill(w.tieVs, { a: src.sides[0] ? v.team(src.sides[0]) : w.tbd, b: src.sides[1] ? v.team(src.sides[1]) : w.tbd }) });
|
|
140
|
+
};
|
|
141
|
+
for (const g of d.panel.games) {
|
|
142
|
+
const t = layout.ties[g];
|
|
143
|
+
const rect = { x: colX(d.depth.get(g)), y: cyOf(g) - gameH / 2, w: d.gameW, h: gameH };
|
|
144
|
+
rects.set(g, rect);
|
|
145
|
+
const rowH = gameH / 2;
|
|
146
|
+
const rows = [0, 1].map((k) => {
|
|
147
|
+
const team = t.sides[k];
|
|
148
|
+
const ry = rect.y + k * rowH;
|
|
149
|
+
const cy = ry + rowH / 2;
|
|
150
|
+
const score = o.scores !== false && t.result.score ? v.number(t.result.score[k]) : "";
|
|
151
|
+
const cls = t.winner === null ? "tb-pending" : t.winner === k ? "tb-won" : "tb-lost";
|
|
152
|
+
const items = [];
|
|
153
|
+
const slotX = rect.x + rect.w - 5 - 26;
|
|
154
|
+
items.push(`<rect class="tb-slot" x="${num(slotX)}" y="${num(cy - 9)}" width="26" height="18" rx="4"/>`);
|
|
155
|
+
if (score)
|
|
156
|
+
items.push(`<text class="tb-score" x="${num(slotX + 13)}" y="${num(cy + 4.5)}" font-size="12.5" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(score)}</text>`);
|
|
157
|
+
let x = rect.x + 6;
|
|
158
|
+
const md = ctx.shape === "none" || !team ? 0 : rowH - 8;
|
|
159
|
+
if (md > 0) {
|
|
160
|
+
items.push(drawMark(team, x + md / 2, cy, md, ctx));
|
|
161
|
+
x += md + 5;
|
|
162
|
+
}
|
|
163
|
+
const room = slotX - 4 - x;
|
|
164
|
+
const text = team ? (room >= bold(team.name, 12) ? team.name : abbrOf(team, v)) : placeholder(t, k);
|
|
165
|
+
items.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(x)}" y="${num(cy + 4.2)}" font-size="12"${team ? "" : ' font-style="italic"'}>${esc(fit(text, 12, Math.max(room, 8), bold))}</text>`);
|
|
166
|
+
return `<g class="${cls}">${items.join("")}</g>`;
|
|
167
|
+
});
|
|
168
|
+
body.push(`<g class="tb-tie${t.winner === null ? "" : " tb-decided"}"><title>${esc(describeTie(t, v))}</title><rect class="tb-box" x="${num(rect.x)}" y="${num(rect.y)}" width="${num(rect.w)}" height="${num(rect.h)}" rx="7"/>${rows.join("")}<line class="tb-line" x1="${num(rect.x + 4)}" x2="${num(rect.x + rect.w - 4)}" y1="${num(rect.y + rowH)}" y2="${num(rect.y + rowH)}" stroke-opacity=".35"/></g>`);
|
|
169
|
+
}
|
|
170
|
+
// The edges out of each game, to see where its winner's and its loser's arrows leave from.
|
|
171
|
+
const out = new Map();
|
|
172
|
+
const addOut = (g, r) => out.set(g, (out.get(g) ?? new Set()).add(r));
|
|
173
|
+
for (const g of d.panel.games)
|
|
174
|
+
for (const s of sources(g))
|
|
175
|
+
if (inPlayIn.has(s.tie))
|
|
176
|
+
addOut(s.tie, s.result);
|
|
177
|
+
for (const s of d.panel.slots)
|
|
178
|
+
addOut(s.from, s.result);
|
|
179
|
+
const leaveY = (g, result) => {
|
|
180
|
+
const kinds = out.get(g);
|
|
181
|
+
const cy = cyOf(g);
|
|
182
|
+
return kinds && kinds.size > 1 ? cy + (result === "winner" ? -9 : 9) : cy;
|
|
183
|
+
};
|
|
184
|
+
const arrow = (x0, y0, x1, y1, result) => {
|
|
185
|
+
const xm = x0 + colGap / 2;
|
|
186
|
+
const d1 = y0 === y1 ? `M${num(x0)} ${num(y0)}H${num(x1 - 1)}` : `M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1 - 1)}`;
|
|
187
|
+
return `<path class="tb-line tb-arrow${result === "loser" ? " tb-arrow-loser" : ""}" d="${d1}"/><path class="tb-arrowhead" d="M${num(x1)} ${num(y1)}L${num(x1 - 7)} ${num(y1 - 4)}V${num(y1 + 4)}Z"/>`;
|
|
188
|
+
};
|
|
189
|
+
for (const g of d.panel.games) {
|
|
190
|
+
const rect = rects.get(g);
|
|
191
|
+
for (const s of sources(g)) {
|
|
192
|
+
const from = rects.get(s.tie);
|
|
193
|
+
if (!from)
|
|
194
|
+
continue;
|
|
195
|
+
body.push(arrow(from.x + from.w, leaveY(s.tie, s.result), rect.x, rect.y + gameH / 4 + s.side * (gameH / 2), s.result));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// The seeds the games lead to.
|
|
199
|
+
const slotX = colX(d.maxDepth + 1);
|
|
200
|
+
d.panel.slots.forEach((s) => {
|
|
201
|
+
const src = rects.get(s.from);
|
|
202
|
+
const cy = leaveY(s.from, s.result);
|
|
203
|
+
const rect = { x: slotX, y: cy - slotH / 2, w: d.slotW, h: slotH };
|
|
204
|
+
const winnerOfSrc = layout.ties[s.from].winner;
|
|
205
|
+
const team = winnerOfSrc === null ? null : layout.ties[s.from].sides[s.result === "winner" ? winnerOfSrc : (1 - winnerOfSrc)];
|
|
206
|
+
const seed = s.to.seeds?.[s.side] ?? s.to.sides[s.side]?.seed ?? null;
|
|
207
|
+
const label = seed === null || seed === undefined ? w.tbd : fill(w.seedSlot, { n: v.number(Number(seed)) });
|
|
208
|
+
const items = [];
|
|
209
|
+
if (team) {
|
|
210
|
+
const md = ctx.shape === "none" ? 0 : slotH - 10;
|
|
211
|
+
let x = rect.x + 6;
|
|
212
|
+
// The seed it earned, as a number, before the mark.
|
|
213
|
+
if (seed !== null && seed !== undefined) {
|
|
214
|
+
const sn = v.number(Number(seed));
|
|
215
|
+
items.push(`<text class="tb-seedn" x="${num(x)}" y="${num(cy + 5.5)}" font-size="15" font-weight="800">${esc(sn)}</text>`);
|
|
216
|
+
x += bold(sn, 15) + 6;
|
|
217
|
+
}
|
|
218
|
+
if (md > 0) {
|
|
219
|
+
items.push(drawMark(team, x + md / 2, cy, md, ctx));
|
|
220
|
+
x += md + 4;
|
|
221
|
+
}
|
|
222
|
+
items.push(`<text class="tb-name" x="${num(x)}" y="${num(cy + 4.2)}" font-size="12">${esc(fit(abbrOf(team, v), 12, Math.max(rect.x + rect.w - 6 - x, 8), bold))}</text>`);
|
|
223
|
+
}
|
|
224
|
+
else
|
|
225
|
+
items.push(`<text class="tb-name tb-muted" x="${num(rect.x + rect.w / 2)}" y="${num(cy + 4)}" font-size="11.5" text-anchor="middle">${esc(fit(label, 11.5, rect.w - 12, bold))}</text>`);
|
|
226
|
+
body.push(`<g class="tb-group"><title>${esc(team ? `${label}: ${v.team(team)}` : label)}</title><rect class="tb-box" x="${num(rect.x)}" y="${num(rect.y)}" width="${num(rect.w)}" height="${num(rect.h)}" rx="7"/>${items.join("")}</g>`);
|
|
227
|
+
body.push(arrow(src.x + src.w, cy, rect.x, cy, s.result));
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
const desc = games.map((g) => describeTie(layout.ties[g], v)).join(w.sep);
|
|
231
|
+
return { width: W, height, body, texts: headingTexts(v, o, W, pad), desc: `${w.playIn}: ${desc}${w.end}` };
|
|
232
|
+
}
|
package/dist/render.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { BracketInput, BracketLayout, DrawOptions, Matchup, Size, SizeName } from "./types.ts";
|
|
1
|
+
import type { BracketInput, BracketLayout, DrawOptions, LayoutName, Matchup, Size, SizeName } from "./types.ts";
|
|
2
2
|
import { type Voice } from "./locales.ts";
|
|
3
|
+
/** The names of the layouts, the tree first. */
|
|
4
|
+
export declare const LAYOUTS: readonly LayoutName[];
|
|
3
5
|
/** 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. */
|
|
4
6
|
export declare const SIZES: Readonly<Record<SizeName, Size>>;
|
|
5
7
|
/** The layout with each team's name in the language: what the drawing, the table and the description are written from. */
|
|
@@ -11,3 +13,8 @@ export declare function describeBracket(input: BracketLayout | readonly Matchup[
|
|
|
11
13
|
* The same input and options always give the same string: no clock, no random number, no counter.
|
|
12
14
|
*/
|
|
13
15
|
export declare function drawBracket(input: BracketLayout | readonly Matchup[] | BracketInput, options?: DrawOptions): string;
|
|
16
|
+
/**
|
|
17
|
+
* 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`
|
|
18
|
+
* 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.
|
|
19
|
+
*/
|
|
20
|
+
export declare function bracketStyle(options: DrawOptions): string;
|