@johnmorrisdotca/taisen-brackets 0.3.0 → 0.5.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 +46 -1
- package/NOTICE.md +5 -2
- package/README.md +195 -23
- package/dist/background.d.ts +19 -0
- package/dist/background.js +66 -0
- package/dist/backgrounds-entry.d.ts +2 -0
- package/dist/backgrounds-entry.js +1 -0
- package/dist/family-backgrounds.d.ts +61 -0
- package/dist/family-backgrounds.js +453 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -2
- package/dist/locales.js +30 -0
- package/dist/marks.d.ts +18 -1
- package/dist/marks.js +89 -3
- package/dist/matchup.js +1 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +473 -87
- 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 +259 -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 +14 -1
- package/dist/text.js +50 -4
- package/dist/theme.d.ts +29 -2
- package/dist/theme.js +50 -11
- package/dist/types.d.ts +120 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -3
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The road chart's data, read and checked: the scores split into the two sides, who won each match, the words under each score, the rounds in the order played, and
|
|
2
|
+
// the sentences and the table that say the same in words. A tournament (`layoutBracket`'s input) is turned into the two finalists' roads here. Nothing here draws.
|
|
3
|
+
import { layoutBracket } from "./layout.js";
|
|
4
|
+
import { fill, voiceOf } from "./locales.js";
|
|
5
|
+
import { noteParts } from "./matchup.js";
|
|
6
|
+
import { localizedLayout } from "./render.js";
|
|
7
|
+
import { roundLabel } from "./rounds.js";
|
|
8
|
+
import { abbreviate, esc } from "./text.js";
|
|
9
|
+
const own = (object, key) => object !== undefined && Object.prototype.hasOwnProperty.call(object, key);
|
|
10
|
+
/** A number from a number or from text that is one, else undefined. */
|
|
11
|
+
export function numeric(value) {
|
|
12
|
+
if (typeof value === "number")
|
|
13
|
+
return Number.isFinite(value) ? value : undefined;
|
|
14
|
+
if (typeof value === "string" && /^\s*-?\d+(?:\.\d+)?\s*$/.test(value))
|
|
15
|
+
return Number(value);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const written = (value) => (value === undefined || value === null ? "" : String(value).trim());
|
|
19
|
+
/** The two sides of a score. */
|
|
20
|
+
function split(score) {
|
|
21
|
+
if (Array.isArray(score) && score.length === 2)
|
|
22
|
+
return { mine: written(score[0]), theirs: written(score[1]) };
|
|
23
|
+
const text = written(score);
|
|
24
|
+
const parts = /^(\S+?)\s*[-–—:]\s*(\S+)$/.exec(text);
|
|
25
|
+
if (parts !== null)
|
|
26
|
+
return { mine: parts[1], theirs: parts[2] };
|
|
27
|
+
return { mine: "", theirs: "", whole: text };
|
|
28
|
+
}
|
|
29
|
+
function resultOf(mine, theirs, pens) {
|
|
30
|
+
const a = numeric(mine);
|
|
31
|
+
const b = numeric(theirs);
|
|
32
|
+
if (a === undefined || b === undefined)
|
|
33
|
+
return "unknown";
|
|
34
|
+
if (a !== b)
|
|
35
|
+
return a > b ? "win" : "loss";
|
|
36
|
+
const p = pens === undefined ? undefined : [numeric(pens[0]), numeric(pens[1])];
|
|
37
|
+
if (p !== undefined && p[0] !== undefined && p[1] !== undefined && p[0] !== p[1])
|
|
38
|
+
return p[0] > p[1] ? "win" : "loss";
|
|
39
|
+
return "draw";
|
|
40
|
+
}
|
|
41
|
+
function pensOf(value) {
|
|
42
|
+
return Array.isArray(value) && value.length === 2 && written(value[0]) !== "" && written(value[1]) !== "" ? [written(value[0]), written(value[1])] : undefined;
|
|
43
|
+
}
|
|
44
|
+
function viewOf(m, opponent, round, knockout) {
|
|
45
|
+
const { mine, theirs, whole } = split(m.score);
|
|
46
|
+
const pens = pensOf(m.pens);
|
|
47
|
+
const decided = m.decidedBy === "aet" || m.decidedBy === "pens" || m.decidedBy === "ft" ? m.decidedBy : pens !== undefined ? "pens" : "ft";
|
|
48
|
+
const note = typeof m.note === "string" ? m.note.trim() : undefined;
|
|
49
|
+
return {
|
|
50
|
+
round,
|
|
51
|
+
opponent,
|
|
52
|
+
mine,
|
|
53
|
+
theirs,
|
|
54
|
+
...(whole === undefined ? {} : { whole }),
|
|
55
|
+
result: resultOf(mine, theirs, pens),
|
|
56
|
+
decided,
|
|
57
|
+
...(pens === undefined ? {} : { pens }),
|
|
58
|
+
...(note === undefined ? {} : { note }),
|
|
59
|
+
says: knockout || m.decidedBy !== undefined || pens !== undefined,
|
|
60
|
+
...("homeFirst" in m && typeof m.homeFirst === "boolean" ? { homeFirst: m.homeFirst } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** 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. */
|
|
64
|
+
export function mergeRounds(paths) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const path of paths) {
|
|
67
|
+
let at = 0;
|
|
68
|
+
for (const key of path) {
|
|
69
|
+
const found = out.indexOf(key);
|
|
70
|
+
if (found >= 0)
|
|
71
|
+
at = found + 1;
|
|
72
|
+
else {
|
|
73
|
+
out.splice(at, 0, key);
|
|
74
|
+
at += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
const isTeam = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.name === "string";
|
|
81
|
+
/** A road chart's team as a `Team`, named in the language: `logo` is the mark, and a team with no id is its name. */
|
|
82
|
+
function teamOf(t, voice) {
|
|
83
|
+
const team = { id: t.id ?? t.name, name: t.name };
|
|
84
|
+
for (const key of ["abbr", "seed", "color", "names", "region", "flag"])
|
|
85
|
+
if (t[key] !== undefined)
|
|
86
|
+
Object.assign(team, { [key]: t[key] });
|
|
87
|
+
const mark = t.mark ?? t.logo;
|
|
88
|
+
if (mark !== undefined)
|
|
89
|
+
team.mark = mark;
|
|
90
|
+
return { ...team, name: voice.team(team) };
|
|
91
|
+
}
|
|
92
|
+
/** What a tournament gives a road chart: the two finalists with the matches they played, the final if it was played, and the names of the rounds. */
|
|
93
|
+
function fromTournament(input, options, voice) {
|
|
94
|
+
const layout = localizedLayout("ties" in input && "rounds" in input && "sides" in input ? input : layoutBracket(input), voice);
|
|
95
|
+
const fi = layout.finalIndex;
|
|
96
|
+
if (fi === null)
|
|
97
|
+
throw new TypeError("roadToFinal: the tournament has no final to draw the road to");
|
|
98
|
+
const finalTie = layout.ties[fi];
|
|
99
|
+
let order = [0, 1];
|
|
100
|
+
if (options.team !== undefined) {
|
|
101
|
+
const at = finalTie.sides.findIndex((s) => s !== null && String(s.id) === String(options.team));
|
|
102
|
+
if (at < 0)
|
|
103
|
+
throw new RangeError(`roadToFinal: ${JSON.stringify(options.team)} is not a side of the final, so its road does not lead there`);
|
|
104
|
+
if (at === 1)
|
|
105
|
+
order = [1, 0];
|
|
106
|
+
}
|
|
107
|
+
const labels = { final: roundLabel(layout, layout.rounds, options.roundNames, voice) };
|
|
108
|
+
const matchOf = (team, tie) => {
|
|
109
|
+
const at = tie.sides[0]?.id === team.id ? 0 : 1;
|
|
110
|
+
const other = tie.sides[at === 0 ? 1 : 0];
|
|
111
|
+
const r = tie.result;
|
|
112
|
+
if (other === null || r.score === null)
|
|
113
|
+
return null;
|
|
114
|
+
const note = r.kind === "game" ? undefined : noteParts(tie, voice).join(" · ");
|
|
115
|
+
const key = String(tie.round);
|
|
116
|
+
if (tie.round > 0)
|
|
117
|
+
labels[key] = roundLabel(layout, tie.round, options.roundNames, voice);
|
|
118
|
+
return {
|
|
119
|
+
round: key,
|
|
120
|
+
opponent: other,
|
|
121
|
+
score: [r.score[at], r.score[at === 0 ? 1 : 0]],
|
|
122
|
+
...(r.pens ? { pens: [r.pens[at], r.pens[at === 0 ? 1 : 0]] } : {}),
|
|
123
|
+
decidedBy: r.pens ? "pens" : r.extraTime ? "aet" : "ft",
|
|
124
|
+
...(note !== undefined ? { note } : {}),
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const finalist = (k) => {
|
|
128
|
+
const team = finalTie.sides[k] ?? { id: "tbd", name: voice.words.tbd };
|
|
129
|
+
const mine = finalTie.sides[k] === null ? [] : layout.ties.filter((t) => t.index !== fi && !t.third && t.sides.some((s) => s !== null && s.id === team.id));
|
|
130
|
+
const sorted = [...mine].sort((a, b) => a.round - b.round || (a.date < b.date ? -1 : a.date > b.date ? 1 : a.index - b.index));
|
|
131
|
+
const matches = sorted.map((t) => ({ tie: t, match: matchOf(team, t) })).filter((m) => m.match !== null);
|
|
132
|
+
return {
|
|
133
|
+
...team,
|
|
134
|
+
path: matches.filter((m) => m.tie.round > 0).map((m) => m.match),
|
|
135
|
+
group: matches.filter((m) => m.tie.round <= 0).map(({ match }) => ({ ...match, round: undefined })),
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const first = order[0];
|
|
139
|
+
const finalists = [finalist(first), finalist(order[1])];
|
|
140
|
+
let final;
|
|
141
|
+
const fr = finalTie.result;
|
|
142
|
+
if (fr.score !== null) {
|
|
143
|
+
const second = first === 0 ? 1 : 0;
|
|
144
|
+
final = {
|
|
145
|
+
score: [fr.score[first], fr.score[second]],
|
|
146
|
+
...(fr.pens ? { pens: [fr.pens[first], fr.pens[second]] } : {}),
|
|
147
|
+
decidedBy: fr.pens ? "pens" : fr.extraTime ? "aet" : "ft",
|
|
148
|
+
...(fr.kind === "game" ? {} : { note: noteParts(finalTie, voice).join(" · ") }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { finalists, final, labels };
|
|
152
|
+
}
|
|
153
|
+
/** Read and check what `roadToFinal` was given. */
|
|
154
|
+
export function readModel(input, options) {
|
|
155
|
+
const voice = voiceOf(options);
|
|
156
|
+
const w = voice.words;
|
|
157
|
+
let given;
|
|
158
|
+
let derived = {};
|
|
159
|
+
let tournamentFinal;
|
|
160
|
+
// A list of teams (nothing with `sides`, which every matchup has) is the finalists; a list of matchups, `{ matchups, teams }` or a layout is a tournament.
|
|
161
|
+
const teamsOnly = Array.isArray(input) && input.length > 0 && input.every((f) => typeof f === "object" && f !== null && !("sides" in f));
|
|
162
|
+
if (teamsOnly && input.length !== 2)
|
|
163
|
+
throw new TypeError("roadToFinal takes the two finalists as a list of two teams, each with a path");
|
|
164
|
+
else if (teamsOnly)
|
|
165
|
+
given = input;
|
|
166
|
+
else if (typeof input === "object" && input !== null) {
|
|
167
|
+
const made = fromTournament(input, options, voice);
|
|
168
|
+
given = made.finalists;
|
|
169
|
+
derived = made.labels;
|
|
170
|
+
tournamentFinal = made.final;
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
throw new TypeError("roadToFinal takes the two finalists as a list of two teams, each with a path, or a tournament");
|
|
174
|
+
const teams = given;
|
|
175
|
+
for (const [i, f] of teams.entries()) {
|
|
176
|
+
if (!isTeam(f))
|
|
177
|
+
throw new TypeError(`Finalist ${i + 1} is a team with a name`);
|
|
178
|
+
if (!Array.isArray(f.path))
|
|
179
|
+
throw new TypeError(`Finalist ${i + 1} (${f.name}) needs a path: the list of its matches before the final`);
|
|
180
|
+
if (f.group !== undefined && !Array.isArray(f.group))
|
|
181
|
+
throw new TypeError(`The group of ${f.name} is a list of matches`);
|
|
182
|
+
for (const m of [...f.path, ...(f.group ?? [])]) {
|
|
183
|
+
if (typeof m !== "object" || m === null || !isTeam(m.opponent) || m.score === undefined || m.score === null)
|
|
184
|
+
throw new TypeError(`Every match of ${f.name} has an opponent (a team with a name) and a score`);
|
|
185
|
+
}
|
|
186
|
+
for (const m of f.path)
|
|
187
|
+
if (typeof m.round !== "string" || m.round.trim() === "")
|
|
188
|
+
throw new TypeError(`Every match of the path of ${f.name} has a round`);
|
|
189
|
+
}
|
|
190
|
+
const finalists = teams.map((f) => teamOf(f, voice));
|
|
191
|
+
const path = teams.map((f) => {
|
|
192
|
+
const map = new Map();
|
|
193
|
+
for (const m of f.path)
|
|
194
|
+
map.set(m.round.trim(), viewOf(m, teamOf(m.opponent, voice), m.round.trim(), true));
|
|
195
|
+
return map;
|
|
196
|
+
});
|
|
197
|
+
const group = teams.map((f) => (f.group ?? []).map((m) => viewOf(m, teamOf(m.opponent, voice), "", false)));
|
|
198
|
+
const rounds = mergeRounds(teams.map((f) => f.path.map((m) => m.round.trim())));
|
|
199
|
+
const labels = options.labels;
|
|
200
|
+
const known = {
|
|
201
|
+
"round-of-32": voice.round("Round of 32"),
|
|
202
|
+
"round-of-16": voice.round("Round of 16"),
|
|
203
|
+
"quarter-final": w.roadQuarterFinal,
|
|
204
|
+
"semi-final": w.roadSemiFinal,
|
|
205
|
+
final: voice.round("Final"),
|
|
206
|
+
};
|
|
207
|
+
const model = {
|
|
208
|
+
voice,
|
|
209
|
+
words: w,
|
|
210
|
+
finalists,
|
|
211
|
+
group,
|
|
212
|
+
path,
|
|
213
|
+
rounds,
|
|
214
|
+
label: (key) => (own(labels, key) && typeof labels?.[key] === "string" ? labels[key] : own(derived, key) ? derived[key] : own(known, key) ? known[key] : key),
|
|
215
|
+
groupLabel: typeof options.groupLabel === "string" && options.groupLabel.trim() !== "" ? options.groupLabel.trim() : w.roadGroup,
|
|
216
|
+
title: typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : w.roadTitle,
|
|
217
|
+
subtitle: typeof options.subtitle === "string" && options.subtitle.trim() !== "" ? options.subtitle.trim() : typeof options.title === "string" && options.title.trim() !== "" ? w.roadTitle : undefined,
|
|
218
|
+
};
|
|
219
|
+
const final = options.final ?? tournamentFinal;
|
|
220
|
+
if (final !== undefined) {
|
|
221
|
+
if (typeof final !== "object" || final === null || final.score === undefined || final.score === null)
|
|
222
|
+
throw new TypeError("The final is { score }: the first finalist's score first");
|
|
223
|
+
model.final = viewOf(final, finalists[1], "final", true);
|
|
224
|
+
}
|
|
225
|
+
return model;
|
|
226
|
+
}
|
|
227
|
+
/** The words under a score: FT, AET or the shoot-out (the first side's numbers first), a note in their place, or nothing. */
|
|
228
|
+
export function decidedWords(view, w, mineFirst) {
|
|
229
|
+
if (view.note !== undefined)
|
|
230
|
+
return view.note;
|
|
231
|
+
if (!view.says)
|
|
232
|
+
return "";
|
|
233
|
+
if (view.decided === "aet")
|
|
234
|
+
return w.roadAet;
|
|
235
|
+
if (view.decided === "pens" && view.pens !== undefined)
|
|
236
|
+
return fill(w.roadPens, mineFirst ? { a: view.pens[0], b: view.pens[1] } : { a: view.pens[1], b: view.pens[0] });
|
|
237
|
+
return view.decided === "pens" ? w.roadAet : w.roadFt;
|
|
238
|
+
}
|
|
239
|
+
/** The score of a match as one text, the first side's first. */
|
|
240
|
+
export function scoreText(view, w, mineFirst = true) {
|
|
241
|
+
if (view.whole !== undefined)
|
|
242
|
+
return view.whole;
|
|
243
|
+
return fill(w.scoreText, mineFirst ? { a: view.mine, b: view.theirs } : { a: view.theirs, b: view.mine });
|
|
244
|
+
}
|
|
245
|
+
/** The abbreviation written under a mark: the team's own, else made from its name. */
|
|
246
|
+
export function codeOf(team) {
|
|
247
|
+
return (typeof team.abbr === "string" && team.abbr.trim() !== "" ? team.abbr.trim() : abbreviate(String(team.name ?? ""))).slice(0, 5).toLocaleUpperCase("en");
|
|
248
|
+
}
|
|
249
|
+
function record(name, group, path) {
|
|
250
|
+
const all = [...group, ...path.values()];
|
|
251
|
+
let scored = 0;
|
|
252
|
+
let conceded = 0;
|
|
253
|
+
for (const v of all) {
|
|
254
|
+
const a = numeric(v.mine);
|
|
255
|
+
const b = numeric(v.theirs);
|
|
256
|
+
if (a !== undefined && b !== undefined) {
|
|
257
|
+
scored += a;
|
|
258
|
+
conceded += b;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { name, matches: all.length, won: all.filter((v) => v.result === "win").length, drawn: all.filter((v) => v.result === "draw").length, lost: all.filter((v) => v.result === "loss").length, scored, conceded };
|
|
262
|
+
}
|
|
263
|
+
/** The numbers behind the chart. */
|
|
264
|
+
export function totalsOf(model) {
|
|
265
|
+
const [a, b] = model.finalists;
|
|
266
|
+
const final = model.final;
|
|
267
|
+
const champion = final === undefined || final.result === "unknown" || final.result === "draw" ? null : final.result === "win" ? a.name : b.name;
|
|
268
|
+
return { finalists: [record(a.name, model.group[0], model.path[0]), record(b.name, model.group[1], model.path[1])], rounds: [...model.rounds], played: final !== undefined, champion };
|
|
269
|
+
}
|
|
270
|
+
/** The chart's accessible name. */
|
|
271
|
+
export function nameOf(model) {
|
|
272
|
+
const heading = [model.title, model.subtitle].filter((part) => part !== undefined).join(", ");
|
|
273
|
+
return fill(model.words.roadName, { a: model.finalists[0].name, b: model.finalists[1].name, title: heading });
|
|
274
|
+
}
|
|
275
|
+
/** What the chart shows, in a sentence or two. */
|
|
276
|
+
export function summaryOf(model) {
|
|
277
|
+
const w = model.words;
|
|
278
|
+
const [a, b] = model.finalists;
|
|
279
|
+
const t = totalsOf(model);
|
|
280
|
+
const parts = [fill(w.roadSummary, { a: a.name, b: b.name, an: model.voice.number(t.finalists[0].matches), bn: model.voice.number(t.finalists[1].matches) })];
|
|
281
|
+
const final = model.final;
|
|
282
|
+
if (final === undefined)
|
|
283
|
+
parts.push(w.roadToPlay);
|
|
284
|
+
else {
|
|
285
|
+
parts.push(fill(w.roadFinal, { a: a.name, b: b.name, score: scoreText(final, w), how: decidedWords(final, w, true) || w.roadFt }));
|
|
286
|
+
if (t.champion !== null)
|
|
287
|
+
parts.push(fill(w.roadChampion, { name: t.champion }));
|
|
288
|
+
}
|
|
289
|
+
return parts.join(" ");
|
|
290
|
+
}
|
|
291
|
+
/** One HTML table with a row for every match: the group stage, the knockout rounds in order, and the final. */
|
|
292
|
+
export function tableOf(model, options = {}) {
|
|
293
|
+
const w = model.words;
|
|
294
|
+
const cls = options.className === undefined || options.className.trim() === "" ? "tb-table" : options.className.trim();
|
|
295
|
+
const [a, b] = model.finalists;
|
|
296
|
+
const result = (r) => (r === "win" ? w.roadWon : r === "loss" ? w.roadLost : r === "draw" ? w.roadDrew : "");
|
|
297
|
+
const row = (team, round, v) => `<tr><th scope="row">${esc(team.name)}</th><td>${esc(round)}</td><td>${esc(v.opponent.name)}</td><td>${esc(scoreText(v, w))}</td><td>${esc(decidedWords(v, w, true))}</td><td>${esc(result(v.result))}</td></tr>`;
|
|
298
|
+
const rows = [];
|
|
299
|
+
for (const [i, team] of [a, b].entries()) {
|
|
300
|
+
for (const v of model.group[i])
|
|
301
|
+
rows.push(row(team, model.groupLabel, v));
|
|
302
|
+
for (const key of model.rounds) {
|
|
303
|
+
const v = model.path[i].get(key);
|
|
304
|
+
if (v !== undefined)
|
|
305
|
+
rows.push(row(team, model.label(key), v));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (model.final !== undefined)
|
|
309
|
+
rows.push(row(a, model.label("final"), { ...model.final, opponent: b }));
|
|
310
|
+
const head = [w.roadTableTeam, w.columnRound, w.roadTableOpponent, w.roadTableScore, w.roadTableDecided, w.roadTableResult].map((name) => `<th scope="col">${esc(name)}</th>`).join("");
|
|
311
|
+
return `<table class="${esc(cls)}" lang="${esc(model.voice.locale.code)}" data-kind="road"><caption>${esc(fill(w.roadCaption, { a: a.name, b: b.name }))}</caption><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
|
|
312
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { BracketInput, BracketLayout, DrawOptions, Matchup, SideId, SizeName, Team } from "./types.ts";
|
|
2
|
+
/** A team of a road chart: a `Team` whose `id` is optional (a standalone finalist needs only a name), with `logo` as another name for `mark`. */
|
|
3
|
+
export type RoadTeam = Omit<Team, "id" | "group"> & {
|
|
4
|
+
id?: SideId;
|
|
5
|
+
/** The address or inline SVG of a flag or a crest: another name for `mark`. */
|
|
6
|
+
logo?: string;
|
|
7
|
+
};
|
|
8
|
+
/** A match's score from the finalist's side: `[for, against]` (numbers, or text such as a series' `4`), or one text, `"4-2"`, that is split at its dash. */
|
|
9
|
+
export type RoadScore = readonly [number | string, number | string] | string;
|
|
10
|
+
/** How a match was decided: `ft` (full time, the default), `aet` (after extra time) or `pens` (a shoot-out after extra time). */
|
|
11
|
+
export type RoadDecided = "ft" | "aet" | "pens";
|
|
12
|
+
/** One match on a finalist's way. */
|
|
13
|
+
export interface RoadMatch {
|
|
14
|
+
/** The round, as a key. `round-of-32`, `round-of-16`, `quarter-final` and `semi-final` have words in every language; any other key is written as it is, or as `options.labels` names it. */
|
|
15
|
+
round: string;
|
|
16
|
+
/** The team it played: its name, abbreviation, `mark` or `flag`, and `color`. */
|
|
17
|
+
opponent: RoadTeam;
|
|
18
|
+
/** The score, the finalist's first: `[2, 1]` is a 2-1 win. */
|
|
19
|
+
score: RoadScore;
|
|
20
|
+
/** How it was decided: `ft` by default, or `pens` when `pens` is given. Written under the score: FT, AET, or the shoot-out. */
|
|
21
|
+
decidedBy?: RoadDecided;
|
|
22
|
+
/** The shoot-out, the finalist's first: `[4, 3]`. */
|
|
23
|
+
pens?: readonly [number | string, number | string];
|
|
24
|
+
/** The order of the pair, left to right: `true` the finalist first, `false` its opponent first. By default the finalist is at the outer edge of its side, so that the two paths mirror each other. */
|
|
25
|
+
homeFirst?: boolean;
|
|
26
|
+
/** Words to write under the score in place of FT, AET or the shoot-out (`Game 6`, `in 7`). An empty text writes nothing. */
|
|
27
|
+
note?: string;
|
|
28
|
+
}
|
|
29
|
+
/** One match of the group stage: as a knockout match, without a round. */
|
|
30
|
+
export type RoadGroupMatch = Omit<RoadMatch, "round"> & {
|
|
31
|
+
round?: string;
|
|
32
|
+
};
|
|
33
|
+
/** A finalist: a team and the matches it played on the way, the earliest first. */
|
|
34
|
+
export interface RoadFinalist extends RoadTeam {
|
|
35
|
+
/** The knockout matches before the final, the earliest first. */
|
|
36
|
+
path: readonly RoadMatch[];
|
|
37
|
+
/** The group stage, in the order played. A row of small marks, codes and scores at the foot. */
|
|
38
|
+
group?: readonly RoadGroupMatch[];
|
|
39
|
+
}
|
|
40
|
+
/** The final, when it has been played. */
|
|
41
|
+
export interface RoadFinal {
|
|
42
|
+
/** The score, the first finalist's first. */
|
|
43
|
+
score: RoadScore;
|
|
44
|
+
decidedBy?: RoadDecided;
|
|
45
|
+
/** The shoot-out, the first finalist's first. */
|
|
46
|
+
pens?: readonly [number | string, number | string];
|
|
47
|
+
note?: string;
|
|
48
|
+
}
|
|
49
|
+
/** The sizes of the road chart are the bracket's: `card` (the default here), `square`, `wide` (the finalists at the two ends) and `phone`. */
|
|
50
|
+
export type RoadSize = SizeName;
|
|
51
|
+
/** What `roadToFinal` takes besides the finalists or the tournament. */
|
|
52
|
+
export interface RoadOptions extends Pick<DrawOptions, "locale" | "words" | "teamName" | "roundNames" | "markShape" | "flags" | "flagHref" | "theme" | "background" | "id" | "className"> {
|
|
53
|
+
/** With a tournament: the finalist whose road is on the left (by team id); the other finalist is its opponent in the final. Left out, the first side of the final. */
|
|
54
|
+
team?: SideId;
|
|
55
|
+
/** The tournament, set large at the top. The language's "Road to the final" when missing. */
|
|
56
|
+
title?: string;
|
|
57
|
+
/** The line under it. "Road to the final" when a `title` is given. */
|
|
58
|
+
subtitle?: string;
|
|
59
|
+
/** The final, if it has been played: its score is written between the two big marks, the winner is brighter, and the table has a row for it. A tournament's own final is used when this is left out. */
|
|
60
|
+
final?: RoadFinal;
|
|
61
|
+
/** The names of the rounds, by key, over the language's own words: `{ "first-round": "First round" }`. */
|
|
62
|
+
labels?: Readonly<Record<string, string>>;
|
|
63
|
+
/** The group stage's name, over the language's own. */
|
|
64
|
+
groupLabel?: string;
|
|
65
|
+
/** `card` (the default), `square`, `wide` or `phone`. */
|
|
66
|
+
size?: RoadSize;
|
|
67
|
+
/** Who is brighter: `auto` (the default) the side that won each match, `none` nobody. */
|
|
68
|
+
winner?: "auto" | "none";
|
|
69
|
+
/** Write the opponent's three-letter code under its mark. On by default. */
|
|
70
|
+
codes?: boolean;
|
|
71
|
+
/** A line at the foot: a source, a credit, "Made-up results". */
|
|
72
|
+
endnote?: string;
|
|
73
|
+
}
|
|
74
|
+
/** One finalist's record on the way. */
|
|
75
|
+
export interface RoadRecord {
|
|
76
|
+
name: string;
|
|
77
|
+
/** Matches played in the group and the knockout rounds (not the final), and how many it won, drew and lost. */
|
|
78
|
+
matches: number;
|
|
79
|
+
won: number;
|
|
80
|
+
drawn: number;
|
|
81
|
+
lost: number;
|
|
82
|
+
/** The goals it scored and let in over the matches whose score is a pair of numbers. */
|
|
83
|
+
scored: number;
|
|
84
|
+
conceded: number;
|
|
85
|
+
}
|
|
86
|
+
/** The numbers behind a road chart. */
|
|
87
|
+
export interface RoadTotals {
|
|
88
|
+
finalists: [RoadRecord, RoadRecord];
|
|
89
|
+
/** The rounds of the chart, the earliest first, as keys. */
|
|
90
|
+
rounds: string[];
|
|
91
|
+
/** The final has a score. */
|
|
92
|
+
played: boolean;
|
|
93
|
+
/** The winner of the final, or null when it has not been played or is level. */
|
|
94
|
+
champion: string | null;
|
|
95
|
+
}
|
|
96
|
+
/** What `roadToFinal` takes: the two finalists with their paths, or a tournament (a list of matchups, `{ matchups, teams }` or a layout) whose final has two sides. */
|
|
97
|
+
export type RoadInput = readonly [RoadFinalist, RoadFinalist] | BracketLayout | readonly Matchup[] | BracketInput;
|
|
98
|
+
/** What `roadToFinal` gives back. */
|
|
99
|
+
export interface RoadChart {
|
|
100
|
+
/** The options the chart was made with: pass changes to `draw`. */
|
|
101
|
+
options: RoadOptions;
|
|
102
|
+
/** The chart's accessible name, in the chart's language. */
|
|
103
|
+
title: string;
|
|
104
|
+
/** What the chart shows, in a sentence or two, for a caption. */
|
|
105
|
+
summary: string;
|
|
106
|
+
/** The numbers behind it. */
|
|
107
|
+
totals: RoadTotals;
|
|
108
|
+
/** The drawing as an SVG string. `extra` is laid over the options (`size`, `theme`, `locale`, `labels`, `final`…). */
|
|
109
|
+
draw(extra?: Partial<RoadOptions>): string;
|
|
110
|
+
/** One HTML table with a row for every match: the table that stands for the drawing for a screen reader. */
|
|
111
|
+
table(options?: {
|
|
112
|
+
className?: string;
|
|
113
|
+
locale?: RoadOptions["locale"];
|
|
114
|
+
}): string;
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/text.d.ts
CHANGED
|
@@ -7,10 +7,23 @@ export declare function esc(text: string | number): string;
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function textWidth(text: string, size: number): number;
|
|
9
9
|
/** The text cut to fit a width, with an ellipsis where it was cut. */
|
|
10
|
-
export declare function fit(text: string, size: number, width: number): string;
|
|
10
|
+
export declare function fit(text: string, size: number, width: number, measure?: (text: string, size: number) => number): string;
|
|
11
11
|
/** A short name for a small box: the initials of a name of several words ("NYR"), else its first three letters; capitals. */
|
|
12
12
|
export declare function abbreviate(name: string): string;
|
|
13
13
|
/** A small, stable hash of a string as lowercase base 36, for ids that depend on the content and nothing else. */
|
|
14
14
|
export declare function hash(text: string): string;
|
|
15
|
+
/** An estimate of the width of a string set in a bold face, in ems; full-width characters are one em. A little generous, so that a name stays inside its place whichever font the page falls back to. */
|
|
16
|
+
export declare function emWidth(text: string, bold?: boolean): number;
|
|
17
|
+
/** The text in capitals when it has Latin letters; any other text is left as it is. */
|
|
18
|
+
export declare function capitals(text: string): string;
|
|
19
|
+
/** The lines a name is set in, the font size that fits them, and the width and height they take. */
|
|
20
|
+
export interface FittedLines {
|
|
21
|
+
lines: string[];
|
|
22
|
+
size: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
/** A name in the biggest size that fits a box, on one line or, at a space, on two (two only when that is clearly bigger). */
|
|
27
|
+
export declare function fitLines(name: string, maxWidth: number, maxHeight: number, maxSize: number): FittedLines;
|
|
15
28
|
/** A number written without float noise: at most two decimals, no trailing zeros. */
|
|
16
29
|
export declare function num(n: number): string;
|
package/dist/text.js
CHANGED
|
@@ -29,13 +29,13 @@ export function textWidth(text, size) {
|
|
|
29
29
|
return em * size;
|
|
30
30
|
}
|
|
31
31
|
/** The text cut to fit a width, with an ellipsis where it was cut. */
|
|
32
|
-
export function fit(text, size, width) {
|
|
33
|
-
if (
|
|
32
|
+
export function fit(text, size, width, measure = textWidth) {
|
|
33
|
+
if (measure(text, size) <= width)
|
|
34
34
|
return text;
|
|
35
35
|
const chars = [...text];
|
|
36
|
-
while (chars.length > 1 &&
|
|
36
|
+
while (chars.length > 1 && measure(`${chars.join("")}…`, size) > width)
|
|
37
37
|
chars.pop();
|
|
38
|
-
return chars.length <= 1 &&
|
|
38
|
+
return chars.length <= 1 && measure(text, size) > width * 2 ? "…" : `${chars.join("").trimEnd()}…`;
|
|
39
39
|
}
|
|
40
40
|
/** A short name for a small box: the initials of a name of several words ("NYR"), else its first three letters; capitals. */
|
|
41
41
|
export function abbreviate(name) {
|
|
@@ -53,6 +53,52 @@ export function hash(text) {
|
|
|
53
53
|
}
|
|
54
54
|
return h.toString(36);
|
|
55
55
|
}
|
|
56
|
+
const NARROW_B = new Set("iIjl!|.,:;'`");
|
|
57
|
+
/** An estimate of the width of a string set in a bold face, in ems; full-width characters are one em. A little generous, so that a name stays inside its place whichever font the page falls back to. */
|
|
58
|
+
export function emWidth(text, bold = true) {
|
|
59
|
+
let width = 0;
|
|
60
|
+
for (const ch of text) {
|
|
61
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
62
|
+
if (code >= 0x2e80)
|
|
63
|
+
width += 1.02;
|
|
64
|
+
else if (ch === " ")
|
|
65
|
+
width += 0.3;
|
|
66
|
+
else if (NARROW_B.has(ch))
|
|
67
|
+
width += 0.34;
|
|
68
|
+
else if (WIDE.has(ch))
|
|
69
|
+
width += 0.92;
|
|
70
|
+
else if (ch >= "0" && ch <= "9")
|
|
71
|
+
width += 0.62;
|
|
72
|
+
else if (ch >= "A" && ch <= "Z")
|
|
73
|
+
width += 0.7;
|
|
74
|
+
else if (ch === "-" || ch === "–" || ch === "—")
|
|
75
|
+
width += 0.45;
|
|
76
|
+
else
|
|
77
|
+
width += 0.58;
|
|
78
|
+
}
|
|
79
|
+
return bold ? width * 1.04 : width;
|
|
80
|
+
}
|
|
81
|
+
/** The text in capitals when it has Latin letters; any other text is left as it is. */
|
|
82
|
+
export function capitals(text) {
|
|
83
|
+
return /[a-z]/i.test(text) ? text.toLocaleUpperCase("en") : text;
|
|
84
|
+
}
|
|
85
|
+
const LINE_HEIGHT = 1.1;
|
|
86
|
+
/** A name in the biggest size that fits a box, on one line or, at a space, on two (two only when that is clearly bigger). */
|
|
87
|
+
export function fitLines(name, maxWidth, maxHeight, maxSize) {
|
|
88
|
+
const w1 = emWidth(name);
|
|
89
|
+
const size1 = Math.max(0.1, Math.min(maxSize, maxHeight / LINE_HEIGHT, w1 === 0 ? maxSize : maxWidth / w1));
|
|
90
|
+
const single = { lines: [name], size: size1, width: w1 * size1, height: size1 * LINE_HEIGHT };
|
|
91
|
+
let two;
|
|
92
|
+
const words = name.split(" ");
|
|
93
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
94
|
+
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
|
|
95
|
+
const w = Math.max(emWidth(a), emWidth(b));
|
|
96
|
+
const size = Math.max(0.1, Math.min(maxSize, maxHeight / (2 * LINE_HEIGHT), w === 0 ? maxSize : maxWidth / w));
|
|
97
|
+
if (two === undefined || size > two.size)
|
|
98
|
+
two = { lines: [a, b], size, width: w * size, height: 2 * size * LINE_HEIGHT };
|
|
99
|
+
}
|
|
100
|
+
return two !== undefined && two.size > single.size * 1.18 ? two : single;
|
|
101
|
+
}
|
|
56
102
|
/** A number written without float noise: at most two decimals, no trailing zeros. */
|
|
57
103
|
export function num(n) {
|
|
58
104
|
if (!Number.isFinite(n))
|
package/dist/theme.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BackgroundInk } from "./family-backgrounds.ts";
|
|
1
2
|
import type { ThemeName } from "./types.ts";
|
|
2
3
|
/** The colours of one look. Each is the fallback of a CSS custom property of the same name: `--tb-ink` and so on. */
|
|
3
4
|
export interface Palette {
|
|
@@ -13,9 +14,35 @@ export interface Palette {
|
|
|
13
14
|
}
|
|
14
15
|
/** The looks. Every colour is the fallback for a custom property, so a page can restyle a drawing with `--tb-ink: …` and nothing else. */
|
|
15
16
|
export declare const THEMES: Readonly<Record<"light" | "dark" | "poster", Palette>>;
|
|
17
|
+
/** What the road look adds to a palette: the ring and the hairline round a mark, the pill behind a round's name, and the road's dots. Custom properties `--tb-mark-ring`, `--tb-mark-edge`, `--tb-panel` and `--tb-spine`. */
|
|
18
|
+
export interface RoadPalette {
|
|
19
|
+
ring: string;
|
|
20
|
+
edge: string;
|
|
21
|
+
panel: string;
|
|
22
|
+
spine: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const ROAD_THEMES: Readonly<Record<"light" | "dark" | "poster", RoadPalette>>;
|
|
25
|
+
/** What a drawing's stylesheet needs besides the classic rules. */
|
|
26
|
+
export interface StyleParts {
|
|
27
|
+
/** The bracket's road look: round marks with a wide ring, big bold scores, the loser quiet, pills for the round names. */
|
|
28
|
+
road?: boolean;
|
|
29
|
+
/** The road-to-the-final chart's rules. */
|
|
30
|
+
chart?: boolean;
|
|
31
|
+
/** The drawing has links: a hover and a focus ring. */
|
|
32
|
+
links?: boolean;
|
|
33
|
+
/** A team's path is highlighted: the other ties are quieter and the path is dotted. */
|
|
34
|
+
follow?: boolean;
|
|
35
|
+
/** A background with a light and a dark picture in one drawing: the one that does not fit the look is hidden. */
|
|
36
|
+
bgAuto?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** The colours a background says to draw in (`backgroundSvg`'s `ink`): what `styleFor` takes in place of a look's own palette. */
|
|
39
|
+
export interface BackgroundInks {
|
|
40
|
+
light?: BackgroundInk;
|
|
41
|
+
dark?: BackgroundInk;
|
|
42
|
+
}
|
|
16
43
|
/**
|
|
17
44
|
* The `<style>` text of a drawing. `auto` is the light look and, under `prefers-color-scheme: dark` or a page that says
|
|
18
45
|
* `data-theme="dark"` on `<html>`, the dark one; `light`, `dark` and `poster` are one look always. Every colour can be
|
|
19
|
-
* set from the page with a `--tb-*` custom property.
|
|
46
|
+
* set from the page with a `--tb-*` custom property. `parts` adds the rules the road look, the road chart, links and a highlighted path need.
|
|
20
47
|
*/
|
|
21
|
-
export declare function styleFor(scope: string, theme: Exclude<ThemeName, "none"
|
|
48
|
+
export declare function styleFor(scope: string, theme: Exclude<ThemeName, "none">, parts?: StyleParts, inks?: BackgroundInks): string;
|