@johnmorrisdotca/taisen-brackets 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/NOTICE.md +3 -2
- package/README.md +140 -9
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/locales.js +30 -0
- package/dist/marks.d.ts +16 -1
- package/dist/marks.js +58 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +236 -74
- package/dist/road-entry.d.ts +2 -0
- package/dist/road-entry.js +1 -0
- package/dist/road.d.ts +28 -0
- package/dist/road.draw.d.ts +4 -0
- package/dist/road.draw.js +256 -0
- package/dist/road.js +58 -0
- package/dist/road.model.d.ts +63 -0
- package/dist/road.model.js +312 -0
- package/dist/road.types.d.ts +115 -0
- package/dist/road.types.js +1 -0
- package/dist/text.d.ts +13 -0
- package/dist/text.js +46 -0
- package/dist/theme.d.ts +21 -2
- package/dist/theme.js +35 -11
- package/dist/types.d.ts +70 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +12 -3
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// The road chart, drawn: the two finalists' big round marks with their names, the round names down the middle on a dotted road, each finalist's matches on its side as
|
|
2
|
+
// the round marks of both teams with the score between them, and the group stage as a compact row at the foot. Each size has its own arrangement: `card`, `square` and
|
|
3
|
+
// `phone` stack the finalists over the rounds; `wide` puts them at the two ends. Nothing is measured in a browser: type is set by estimates (`emWidth`).
|
|
4
|
+
import { drawMark, markDefs } from "./marks.js";
|
|
5
|
+
import { codeOf, decidedWords, nameOf, summaryOf } from "./road.model.js";
|
|
6
|
+
import { SIZES } from "./render.js";
|
|
7
|
+
import { styleFor } from "./theme.js";
|
|
8
|
+
import { capitals, emWidth, esc, fitLines, num } from "./text.js";
|
|
9
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
10
|
+
/** A text centred on `y`, `size` high. */
|
|
11
|
+
const text = (cls, x, y, size, content, anchor = "middle") => `<text class="${cls}" x="${num(x)}" y="${num(y + size * 0.36)}" font-size="${num(size)}" text-anchor="${anchor}">${esc(content)}</text>`;
|
|
12
|
+
/** The width of a match's score block in ems of its number size: the two numbers and the dash. */
|
|
13
|
+
const scoreEms = (v) => (v.whole !== undefined ? emWidth(v.whole) : emWidth(v.mine) + emWidth(v.theirs) + 1.1);
|
|
14
|
+
/** The group stage's score as one text. */
|
|
15
|
+
const sub0 = (v) => (v.note !== undefined ? v.note !== "" : v.says);
|
|
16
|
+
const groupScore = (v) => v.whole ?? `${v.mine}–${v.theirs}`;
|
|
17
|
+
/** Draw the chart as an SVG string. `gid` is the prefix of its ids. */
|
|
18
|
+
export function drawRoad(model, o, gid) {
|
|
19
|
+
const size = o.size !== undefined && Object.prototype.hasOwnProperty.call(SIZES, o.size) ? o.size : "card";
|
|
20
|
+
const info = SIZES[size];
|
|
21
|
+
const w = model.words;
|
|
22
|
+
const W = info.width;
|
|
23
|
+
const phone = size === "phone";
|
|
24
|
+
const wide = size === "wide";
|
|
25
|
+
const [fa, fb] = model.finalists;
|
|
26
|
+
const rounds = [...model.rounds].reverse();
|
|
27
|
+
const R = rounds.length;
|
|
28
|
+
const nGroup = Math.max(model.group[0].length, model.group[1].length);
|
|
29
|
+
const emphasis = o.winner !== "none";
|
|
30
|
+
const codes = o.codes !== false;
|
|
31
|
+
const theme = o.theme ?? "auto";
|
|
32
|
+
const ctx = {
|
|
33
|
+
radii: new Set(),
|
|
34
|
+
symbols: new Map(),
|
|
35
|
+
prefix: gid,
|
|
36
|
+
shape: o.markShape === "square" ? "square" : "round",
|
|
37
|
+
look: "road",
|
|
38
|
+
flags: o.flags,
|
|
39
|
+
theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit",
|
|
40
|
+
flagCache: new Map(),
|
|
41
|
+
};
|
|
42
|
+
const markOf = (team, cx, cy, r, cls) => `<g class="${cls}">${drawMark(team, cx, cy, 2 * r, ctx, codeOf(team).slice(0, 3))}</g>`;
|
|
43
|
+
// ---- The type and the room round the edge. ----
|
|
44
|
+
const tall = info.height ?? 1e9;
|
|
45
|
+
const pad = phone ? 16 : Math.round(Math.min(W * 0.045, tall * 0.05));
|
|
46
|
+
const ts = clamp(Math.min(W * 0.044, tall * 0.056), 17, 48);
|
|
47
|
+
const es = clamp(Math.min(W * 0.0185, tall * 0.022), 9, 20);
|
|
48
|
+
const room = W - 2 * pad;
|
|
49
|
+
const titleFit = fitLines(model.title, room * 0.94, ts * 2.3, ts);
|
|
50
|
+
const subSize = model.subtitle === undefined ? 0 : Math.min(ts * 0.5, (room * 0.94) / Math.max(1, emWidth(model.subtitle)));
|
|
51
|
+
const headH = titleFit.height + (model.subtitle === undefined ? 0 : subSize * 1.5) + ts * 0.45;
|
|
52
|
+
const note = o.endnote === undefined ? "" : String(o.endnote).trim();
|
|
53
|
+
const endSize = note === "" ? 0 : Math.min(es, room / Math.max(1, emWidth(note, false)));
|
|
54
|
+
const footH = endSize === 0 ? 0 : endSize * 2.4;
|
|
55
|
+
// ---- The columns: the middle one holds the rounds' names; a wide chart has a column for each finalist at its end. ----
|
|
56
|
+
const cl = phone ? 70 : wide ? W * 0.12 : W * 0.175;
|
|
57
|
+
const fc = wide ? W * 0.17 : 0;
|
|
58
|
+
const sideW = (W - 2 * pad - cl - 2 * fc) / 2;
|
|
59
|
+
const lanes = [
|
|
60
|
+
{ x0: pad + fc, x1: pad + fc + sideW },
|
|
61
|
+
{ x0: W - pad - fc - sideW, x1: W - pad - fc },
|
|
62
|
+
];
|
|
63
|
+
const cx = W / 2;
|
|
64
|
+
// ---- The group stage: how many marks in a row, and how large. ----
|
|
65
|
+
const maxCols = Math.max(1, Math.floor(sideW / (phone ? 46 : W * 0.062)));
|
|
66
|
+
const cols = nGroup === 0 ? 1 : Math.min(maxCols, nGroup <= 4 ? nGroup : Math.ceil(nGroup / 2));
|
|
67
|
+
const groupRows = nGroup === 0 ? 0 : Math.ceil(nGroup / cols);
|
|
68
|
+
const cw = sideW / cols;
|
|
69
|
+
const dm = Math.min(cw * 0.56, phone ? 36 : Math.min(W * 0.052, tall * 0.065));
|
|
70
|
+
const cellH = dm * 2.08;
|
|
71
|
+
const groupBlock = nGroup === 0 ? 0 : groupRows * cellH + (groupRows - 1) * dm * 0.2 + (phone ? 26 : dm * 0.1);
|
|
72
|
+
// ---- The big marks, the rows and the vertical room. ----
|
|
73
|
+
const rowLabelH = phone ? 22 : 0;
|
|
74
|
+
let H = info.height ?? 0;
|
|
75
|
+
let D = wide ? Math.min(fc * 0.8, H * 0.36) : Math.min(sideW * 0.68, tall * 0.19);
|
|
76
|
+
const names = (d) => [fa, fb].map((t) => fitLines(capitals(t.name), (wide ? fc : sideW) * 0.96, d * 0.17 * 2.3, d * 0.17));
|
|
77
|
+
const slots = wide ? R + 1 : R;
|
|
78
|
+
const minRow = W * 0.085;
|
|
79
|
+
let rowH = phone ? 76 : 0;
|
|
80
|
+
let bandH = 0;
|
|
81
|
+
let regionTop = 0;
|
|
82
|
+
let regionBottom = 0;
|
|
83
|
+
const gapS = phone ? 14 : (info.height ?? 1000) * 0.016;
|
|
84
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
85
|
+
bandH = D + D * 0.08 + Math.max(...names(D).map((n) => n.height));
|
|
86
|
+
if (phone) {
|
|
87
|
+
regionTop = pad + headH + bandH + gapS;
|
|
88
|
+
regionBottom = regionTop + R * (rowH + rowLabelH);
|
|
89
|
+
H = regionBottom + (nGroup > 0 ? gapS + groupBlock : 0) + footH + pad;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
regionTop = pad + headH + (wide ? 0 : bandH + gapS);
|
|
93
|
+
regionBottom = H - pad - footH - (nGroup > 0 ? groupBlock + gapS : 0);
|
|
94
|
+
rowH = slots > 0 ? (regionBottom - regionTop) / slots : 0;
|
|
95
|
+
if (wide || rowH >= minRow || D < sideW * 0.25)
|
|
96
|
+
break;
|
|
97
|
+
D *= 0.86;
|
|
98
|
+
}
|
|
99
|
+
rowH = Math.min(rowH, W * 0.2);
|
|
100
|
+
// ---- The pieces of a row: the marks, the numbers, the gaps. ----
|
|
101
|
+
const subRatio = 0.42;
|
|
102
|
+
let ns = rowH * 0.34;
|
|
103
|
+
let d = rowH * 0.58;
|
|
104
|
+
let ems = 2.2;
|
|
105
|
+
for (const key of rounds) {
|
|
106
|
+
for (const i of [0, 1]) {
|
|
107
|
+
const v = model.path[i].get(key);
|
|
108
|
+
if (v !== undefined)
|
|
109
|
+
ems = Math.max(ems, scoreEms(v), emWidth(decidedWords(v, w, true), false) * subRatio);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const fit = Math.min(1, (sideW * 0.97) / (2 * d + 2 * d * 0.14 + ems * ns));
|
|
113
|
+
ns *= fit;
|
|
114
|
+
d *= fit;
|
|
115
|
+
const pair = 2 * d + 2 * d * 0.14 + ems * ns;
|
|
116
|
+
// The top of a row, the first being the last round. A wide chart's first slot is the final.
|
|
117
|
+
const rowTop = (i) => {
|
|
118
|
+
if (phone)
|
|
119
|
+
return regionTop + i * (rowH + rowLabelH) + rowLabelH;
|
|
120
|
+
return regionTop + (regionBottom - regionTop - slots * rowH) / 2 + (wide ? i + 1 : i) * rowH;
|
|
121
|
+
};
|
|
122
|
+
const dimOf = (result, forFinalist) => (emphasis && ((forFinalist && result === "loss") || (!forFinalist && result === "win")) ? " tb-r-dim" : "");
|
|
123
|
+
// The round's name: a panel in the middle column, or a line over the row on a phone.
|
|
124
|
+
const panel = (words, yc, height, width) => {
|
|
125
|
+
const fitted = fitLines(words, width * 0.88, height * 0.74, height * 0.54);
|
|
126
|
+
const lines = fitted.lines.map((line, i) => text("tb-lab", cx, yc + (i - (fitted.lines.length - 1) / 2) * fitted.size * 1.1, fitted.size, line));
|
|
127
|
+
return `<rect class="tb-panel" x="${num(cx - width / 2)}" y="${num(yc - height / 2)}" width="${num(width)}" height="${num(height)}" rx="${num(height / 2)}"/>${lines.join("")}`;
|
|
128
|
+
};
|
|
129
|
+
const label = (key, yc) => {
|
|
130
|
+
const words = model.label(key);
|
|
131
|
+
if (!phone)
|
|
132
|
+
return panel(words, yc, Math.min(rowH * 0.34, cl * 0.2), cl * 0.94);
|
|
133
|
+
const y = yc - rowH / 2 - rowLabelH / 2;
|
|
134
|
+
const width = emWidth(words) * 11.5 + 20;
|
|
135
|
+
return `<rect class="tb-panel" x="${num(cx - width / 2)}" y="${num(y - 9)}" width="${num(width)}" height="18" rx="9"/>${text("tb-lab", cx, y, 11.5, words)}`;
|
|
136
|
+
};
|
|
137
|
+
// One finalist's match in a row: the two marks and the score between them.
|
|
138
|
+
const matchMarkup = (lane, side, yc, v) => {
|
|
139
|
+
const left = v.homeFirst !== undefined ? v.homeFirst : side === 0;
|
|
140
|
+
const xc = (lane.x0 + lane.x1) / 2;
|
|
141
|
+
const y = yc - rowH * 0.04;
|
|
142
|
+
const lx = xc - pair / 2 + d / 2;
|
|
143
|
+
const rx = xc + pair / 2 - d / 2;
|
|
144
|
+
let out = markOf(model.finalists[side], left ? lx : rx, y, d / 2, `tb-r-fin${dimOf(v.result, true)}`);
|
|
145
|
+
out += markOf(v.opponent, left ? rx : lx, y, d / 2, `tb-r-opp${dimOf(v.result, false)}`);
|
|
146
|
+
if (codes)
|
|
147
|
+
out += text(`tb-r-code${dimOf(v.result, false)}`, left ? rx : lx, y + d / 2 + d * 0.2, d * 0.2, codeOf(v.opponent));
|
|
148
|
+
out += scoreMarkup(v, xc, y, ns, left);
|
|
149
|
+
const sub = decidedWords(v, w, left);
|
|
150
|
+
return sub === "" ? out : out + text("tb-r-sub", xc, y + ns * 0.82, ns * subRatio, sub);
|
|
151
|
+
};
|
|
152
|
+
// The score: the two numbers either side of a dash, the loser's quiet. The first side's number is on the left when `left`.
|
|
153
|
+
const scoreMarkup = (v, x, y, n, left) => {
|
|
154
|
+
if (v.whole !== undefined)
|
|
155
|
+
return text("tb-r-n", x, y, n * 0.9, v.whole);
|
|
156
|
+
const [a, b, lowA, lowB] = left ? [v.mine, v.theirs, v.result === "loss", v.result === "win"] : [v.theirs, v.mine, v.result === "win", v.result === "loss"];
|
|
157
|
+
return (text(`tb-r-n${emphasis && lowA ? " tb-r-low" : ""}`, x - n * 0.4, y, n, a, "end") +
|
|
158
|
+
text("tb-r-dash", x, y, n * 0.8, "–") +
|
|
159
|
+
text(`tb-r-n${emphasis && lowB ? " tb-r-low" : ""}`, x + n * 0.4, y, n, b, "start"));
|
|
160
|
+
};
|
|
161
|
+
let body = "";
|
|
162
|
+
for (const [i, key] of rounds.entries()) {
|
|
163
|
+
const yc = rowTop(i) + rowH / 2;
|
|
164
|
+
body += label(key, yc);
|
|
165
|
+
for (const side of [0, 1]) {
|
|
166
|
+
const v = model.path[side].get(key);
|
|
167
|
+
if (v !== undefined)
|
|
168
|
+
body += matchMarkup(lanes[side], side, yc, v);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ---- The finalists. ----
|
|
172
|
+
const final = model.final;
|
|
173
|
+
const lost = (side) => emphasis && final !== undefined && (side === 0 ? final.result === "loss" : final.result === "win");
|
|
174
|
+
const named = names(D);
|
|
175
|
+
const flagX = wide ? [pad + fc / 2, W - pad - fc / 2] : [(lanes[0].x0 + lanes[0].x1) / 2, (lanes[1].x0 + lanes[1].x1) / 2];
|
|
176
|
+
const flagY = wide ? (regionTop + regionBottom) / 2 - (D * 0.08 + Math.max(...named.map((n) => n.height))) / 2 : pad + headH + (phone ? 0 : (info.height ?? 1000) * 0.008) + D / 2;
|
|
177
|
+
let band = "";
|
|
178
|
+
for (const side of [0, 1]) {
|
|
179
|
+
const dim = lost(side) ? " tb-r-dim" : "";
|
|
180
|
+
band += markOf(model.finalists[side], flagX[side], flagY, D / 2, `tb-r-fin${dim}`);
|
|
181
|
+
const nb = named[side];
|
|
182
|
+
const top = flagY + D / 2 + D * 0.08;
|
|
183
|
+
band += nb.lines.map((line, k) => text(`tb-r-name${dim}`, flagX[side], top + nb.size * 0.55 + k * nb.size * 1.1, nb.size, line)).join("");
|
|
184
|
+
}
|
|
185
|
+
// The final between them: its name over its score, or "v" when it is not played.
|
|
186
|
+
const fy = wide ? rowTop(-1) + rowH / 2 : flagY;
|
|
187
|
+
const labelSize = clamp(cl * 0.13, 9, 26);
|
|
188
|
+
const fs = wide ? Math.min(clamp(cl * 0.27, 14, 58), rowH * 0.36) : clamp(cl * 0.27, 14, 58);
|
|
189
|
+
let middle = text("tb-r-sub", cx, fy - (wide ? fs * 0.95 : Math.min(D * 0.3, cl * 0.34)), labelSize, model.label("final"));
|
|
190
|
+
if (final === undefined)
|
|
191
|
+
middle += text("tb-r-vs", cx, fy + labelSize * 0.2, fs * 0.9, w.roadVs);
|
|
192
|
+
else {
|
|
193
|
+
middle += scoreMarkup(final, cx, fy, fs, true);
|
|
194
|
+
const sub = decidedWords(final, w, true);
|
|
195
|
+
if (sub !== "")
|
|
196
|
+
middle += text("tb-r-sub", cx, fy + fs * 0.85, fs * 0.4, sub);
|
|
197
|
+
}
|
|
198
|
+
// ---- The road: a dotted line down the middle, behind the round names, from the final down to the group stage. ----
|
|
199
|
+
const groupTop = phone ? regionBottom + gapS : H - pad - footH - groupBlock;
|
|
200
|
+
const roadTop = wide ? fy + rowH * 0.42 : fy + (final === undefined ? labelSize * 1.6 : fs * (sub0(final) ? 1.5 : 1.0));
|
|
201
|
+
const roadEnd = nGroup > 0 ? groupTop + (phone ? 0 : cellH / 2) : R > 0 ? rowTop(R - 1) + rowH / 2 : roadTop;
|
|
202
|
+
const spine = R > 0 && roadEnd > roadTop
|
|
203
|
+
? `<path class="tb-spine" d="M${num(cx)} ${num(roadTop)}V${num(roadEnd)}" stroke-width="${num(clamp(W * 0.004, 1.5, 4))}" stroke-dasharray="0.1 ${num(clamp(W * 0.014, 5, 16))}"${theme === "none" ? ' stroke="currentColor" stroke-opacity=".4"' : ""}/>`
|
|
204
|
+
: "";
|
|
205
|
+
// ---- The group stage: small round marks with their codes and scores. ----
|
|
206
|
+
let groupMarkup = "";
|
|
207
|
+
if (nGroup > 0) {
|
|
208
|
+
const gy = groupTop + (phone ? 26 : 0);
|
|
209
|
+
if (phone) {
|
|
210
|
+
const width = emWidth(model.groupLabel) * 11.5 + 20;
|
|
211
|
+
groupMarkup += `<rect class="tb-panel" x="${num(cx - width / 2)}" y="${num(groupTop + 1)}" width="${num(width)}" height="18" rx="9"/>${text("tb-lab", cx, groupTop + 10, 11.5, model.groupLabel)}`;
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
groupMarkup += panel(model.groupLabel, gy + cellH / 2, Math.min(cellH * 0.42, cl * 0.2), cl * 0.94);
|
|
215
|
+
for (const side of [0, 1]) {
|
|
216
|
+
const lane = lanes[side];
|
|
217
|
+
model.group[side].forEach((v, k) => {
|
|
218
|
+
const row = Math.floor(k / cols);
|
|
219
|
+
const inRow = Math.min(cols, model.group[side].length - row * cols);
|
|
220
|
+
const x = (lane.x0 + lane.x1) / 2 - (inRow * cw) / 2 + (k % cols) * cw + cw / 2;
|
|
221
|
+
const y = gy + row * (cellH + dm * 0.2);
|
|
222
|
+
const dim = dimOf(v.result, false);
|
|
223
|
+
groupMarkup += markOf(v.opponent, x, y + dm / 2, dm / 2, `tb-r-opp${dim}`);
|
|
224
|
+
if (codes)
|
|
225
|
+
groupMarkup += text(`tb-r-code${dim}`, x, y + dm * 1.27, dm * 0.3, codeOf(v.opponent));
|
|
226
|
+
groupMarkup += text(`tb-r-n${emphasis && v.result === "loss" ? " tb-r-low" : ""}`, x, y + dm * 1.78, dm * 0.44, groupScore(v));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// ---- The head and the foot. ----
|
|
231
|
+
let head = titleFit.lines.map((line, k) => text("tb-r-t", cx, pad + titleFit.size * 0.55 + k * titleFit.size * 1.1, titleFit.size, line)).join("");
|
|
232
|
+
if (model.subtitle !== undefined)
|
|
233
|
+
head += text("tb-r-s", cx, pad + titleFit.height + subSize * 0.7, subSize, model.subtitle);
|
|
234
|
+
const foot = endSize === 0 ? "" : text("tb-r-e", cx, H - pad - endSize * 0.2, endSize, note);
|
|
235
|
+
const defs = markDefs(ctx);
|
|
236
|
+
const style = theme === "none" ? "" : `<style>${styleFor(`.${gid}`, theme, { chart: true })}</style>`;
|
|
237
|
+
const bg = o.background === false ? "" : `<rect class="tb-bg" x="0" y="0" width="${num(W)}" height="${num(H)}"${theme === "none" ? ' fill="none"' : ""}/>`;
|
|
238
|
+
const extra = o.className ? ` ${esc(o.className)}` : "";
|
|
239
|
+
return [
|
|
240
|
+
`<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(model.voice.locale.code)}" class="taisen tb-road ${gid}${extra}" viewBox="0 0 ${num(W)} ${num(H)}" width="${num(W)}" height="${num(H)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${gid}-t ${gid}-d" data-size="${size}">`,
|
|
241
|
+
`<title id="${gid}-t">${esc(nameOf(model))}</title><desc id="${gid}-d">${esc(summaryOf(model))}</desc>`,
|
|
242
|
+
style,
|
|
243
|
+
defs === "" ? "" : `<defs>${defs}</defs>`,
|
|
244
|
+
bg,
|
|
245
|
+
head,
|
|
246
|
+
spine,
|
|
247
|
+
band,
|
|
248
|
+
middle,
|
|
249
|
+
body,
|
|
250
|
+
groupMarkup,
|
|
251
|
+
foot,
|
|
252
|
+
"</svg>",
|
|
253
|
+
]
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join("\n");
|
|
256
|
+
}
|
package/dist/road.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The road-to-the-final chart: the two finalists of a tournament with the matches each played on the way, as round flags (or crests) of both teams with the score between
|
|
2
|
+
// them, the rounds named down the middle, and the group stage as a compact row at the foot. The idea is that of the "road to the final" graphics that sports sites post
|
|
3
|
+
// for a World Cup or a Stanley Cup; the arrangement, the type and the code are this package's own, and no one's artwork is used: the marks are the caller's flags and
|
|
4
|
+
// logos, or the team's initials on its colour.
|
|
5
|
+
import { SIZES } from "./render.js";
|
|
6
|
+
import { drawRoad } from "./road.draw.js";
|
|
7
|
+
import { nameOf, readModel, summaryOf, tableOf, totalsOf } from "./road.model.js";
|
|
8
|
+
import { hash } from "./text.js";
|
|
9
|
+
const check = (o) => {
|
|
10
|
+
if (o.size !== undefined && !Object.prototype.hasOwnProperty.call(SIZES, o.size))
|
|
11
|
+
throw new RangeError(`Unknown size "${String(o.size)}": use one of ${Object.keys(SIZES).join(", ")}`);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The road to the final: the two finalists as large round marks with their names at the top, each one's matches on its side (the round mark of both teams with the
|
|
15
|
+
* score between them, and FT, AET or the shoot-out under it), the round names down the middle, and the group stage as a compact row of small marks, codes and scores.
|
|
16
|
+
* `input` is a tournament, the same one `drawBracket` draws (a list of matchups, `{ matchups, teams }` or a layout): the two sides of its final are the finalists, each one's
|
|
17
|
+
* played ties (round-robin games, `round: 0`, as the group stage) are its road, and `team` puts one finalist on the left. Or it is the two finalists, each a team (`name`,
|
|
18
|
+
* `abbr`, `mark` or `logo` for the flag or crest, `flag` for a country code drawn by `flags`, `color`) with its `path`, the matches before the final, the earliest first,
|
|
19
|
+
* and optionally its `group`; a match is `{ round, opponent, score: [for, against], decidedBy, pens }`, a round a key (`quarter-final`) with words in every language, or any
|
|
20
|
+
* key you name in `labels`, so a series (`First round`, `4-2`) draws as well as a knockout. A team with no mark is its initials on its colour. The side that won each match
|
|
21
|
+
* is brighter; the sizes are `card` (1080 by 1350, the default), `square`, `wide` (1200 by 675) and `phone` (390 wide). `table()` lists every match as text.
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { roadToFinal, type RoadFinalist } from "@johnmorrisdotca/taisen-brackets";
|
|
25
|
+
* const owls: RoadFinalist = { name: "Northern Owls", abbr: "NOW", color: "#3a2d6b", path: [
|
|
26
|
+
* { round: "quarter-final", opponent: { name: "Coastal Pines", abbr: "CPN" }, score: [2, 0] },
|
|
27
|
+
* { round: "semi-final", opponent: { name: "Valley Stars", abbr: "VAL" }, score: [1, 1], decidedBy: "pens", pens: [4, 3] },
|
|
28
|
+
* ] };
|
|
29
|
+
* const gulls: RoadFinalist = { name: "Harbour City Gulls", abbr: "HCG", color: "#1d4f91", path: [
|
|
30
|
+
* { round: "quarter-final", opponent: { name: "Summit Peaks", abbr: "SMT" }, score: [3, 1] },
|
|
31
|
+
* { round: "semi-final", opponent: { name: "Metro Bolts", abbr: "MTB" }, score: [2, 1], decidedBy: "aet" },
|
|
32
|
+
* ] };
|
|
33
|
+
* const chart = roadToFinal([owls, gulls], { title: "Northern Cup 2030", size: "square" });
|
|
34
|
+
* console.log(chart.totals.rounds.join(" "), chart.draw().startsWith("<svg"), chart.table().includes("Semi-final"));
|
|
35
|
+
* // quarter-final semi-final true true
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function roadToFinal(input, options = {}) {
|
|
39
|
+
if (typeof options !== "object" || options === null)
|
|
40
|
+
throw new TypeError("options is an object");
|
|
41
|
+
check(options);
|
|
42
|
+
const model = readModel(input, options);
|
|
43
|
+
const build = (extra = {}) => {
|
|
44
|
+
const merged = { ...options, ...extra };
|
|
45
|
+
check(merged);
|
|
46
|
+
const m = readModel(input, merged);
|
|
47
|
+
const prefix = merged.id ?? `tb-${hash(JSON.stringify([m.voice.locale.code, m.finalists.map((t) => t.name), [...m.path[0].values()].map((v) => [v.round, v.mine, v.theirs]), [...m.path[1].values()].map((v) => [v.round, v.mine, v.theirs]), m.rounds, merged.size, merged.theme, merged.title, merged.subtitle, merged.final ?? null, merged.endnote, merged.winner, merged.codes, merged.markShape]))}`;
|
|
48
|
+
return drawRoad(m, merged, prefix);
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
options: { ...options },
|
|
52
|
+
title: nameOf(model),
|
|
53
|
+
summary: summaryOf(model),
|
|
54
|
+
totals: totalsOf(model),
|
|
55
|
+
draw: build,
|
|
56
|
+
table: (tableOptions = {}) => tableOf(tableOptions.locale === undefined ? model : readModel(input, { ...options, locale: tableOptions.locale }), tableOptions),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type Voice } from "./locales.ts";
|
|
2
|
+
import type { RoadDecided, RoadInput, RoadOptions, RoadTotals } from "./road.types.ts";
|
|
3
|
+
import type { Team, Words } from "./types.ts";
|
|
4
|
+
/** Who won a match, from the finalist's side. `unknown` when the score is not a pair of numbers. */
|
|
5
|
+
export type Result = "win" | "loss" | "draw" | "unknown";
|
|
6
|
+
/** A match as it is drawn: the two scores written, who won, and how it was decided. */
|
|
7
|
+
export interface MatchView {
|
|
8
|
+
/** The round's key; empty for a match of the group stage. */
|
|
9
|
+
round: string;
|
|
10
|
+
opponent: Team;
|
|
11
|
+
/** The finalist's score and the opponent's, written. For a score that is one text that could not be split, `whole` is that text and the two are empty. */
|
|
12
|
+
mine: string;
|
|
13
|
+
theirs: string;
|
|
14
|
+
whole?: string;
|
|
15
|
+
result: Result;
|
|
16
|
+
decided: RoadDecided;
|
|
17
|
+
pens?: [string, string];
|
|
18
|
+
/** The words that replace the usual ones under the score, or undefined. */
|
|
19
|
+
note?: string;
|
|
20
|
+
/** Whether the usual words (FT, AET, the shoot-out) are written: always for a knockout match, only when asked for a group match. */
|
|
21
|
+
says: boolean;
|
|
22
|
+
homeFirst?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The chart's data, ready to draw. */
|
|
25
|
+
export interface Model {
|
|
26
|
+
voice: Voice;
|
|
27
|
+
words: Words;
|
|
28
|
+
finalists: [Team, Team];
|
|
29
|
+
/** The matches of the group stage, for each finalist. */
|
|
30
|
+
group: [MatchView[], MatchView[]];
|
|
31
|
+
/** The knockout matches for each finalist, by round key. */
|
|
32
|
+
path: [Map<string, MatchView>, Map<string, MatchView>];
|
|
33
|
+
/** The rounds, the earliest first. */
|
|
34
|
+
rounds: string[];
|
|
35
|
+
/** The final, if played: the scores of the first finalist and the second. */
|
|
36
|
+
final?: MatchView;
|
|
37
|
+
label(key: string): string;
|
|
38
|
+
groupLabel: string;
|
|
39
|
+
title: string;
|
|
40
|
+
subtitle: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
/** A number from a number or from text that is one, else undefined. */
|
|
43
|
+
export declare function numeric(value: unknown): number | undefined;
|
|
44
|
+
/** The rounds of two paths, the earliest first: a round both have is once, and a round only one has stays after the round before it. */
|
|
45
|
+
export declare function mergeRounds(paths: readonly (readonly string[])[]): string[];
|
|
46
|
+
/** Read and check what `roadToFinal` was given. */
|
|
47
|
+
export declare function readModel(input: RoadInput, options: RoadOptions): Model;
|
|
48
|
+
/** The words under a score: FT, AET or the shoot-out (the first side's numbers first), a note in their place, or nothing. */
|
|
49
|
+
export declare function decidedWords(view: MatchView, w: Words, mineFirst: boolean): string;
|
|
50
|
+
/** The score of a match as one text, the first side's first. */
|
|
51
|
+
export declare function scoreText(view: MatchView, w: Words, mineFirst?: boolean): string;
|
|
52
|
+
/** The abbreviation written under a mark: the team's own, else made from its name. */
|
|
53
|
+
export declare function codeOf(team: Team): string;
|
|
54
|
+
/** The numbers behind the chart. */
|
|
55
|
+
export declare function totalsOf(model: Model): RoadTotals;
|
|
56
|
+
/** The chart's accessible name. */
|
|
57
|
+
export declare function nameOf(model: Model): string;
|
|
58
|
+
/** What the chart shows, in a sentence or two. */
|
|
59
|
+
export declare function summaryOf(model: Model): string;
|
|
60
|
+
/** One HTML table with a row for every match: the group stage, the knockout rounds in order, and the final. */
|
|
61
|
+
export declare function tableOf(model: Model, options?: {
|
|
62
|
+
className?: string;
|
|
63
|
+
}): string;
|