@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
package/dist/render.js
CHANGED
|
@@ -1,18 +1,75 @@
|
|
|
1
1
|
import { layoutBracket } from "./layout.js";
|
|
2
2
|
import { abbrOf, describeTie, noteParts } from "./matchup.js";
|
|
3
|
-
import { cleanSvg,
|
|
3
|
+
import { cleanSvg, drawEmblem, drawMark, hasTrophy as hasTrophyOf, isEmblemId, markDefs, safeHref, safeUrl } from "./marks.js";
|
|
4
4
|
import { roundLabel } from "./rounds.js";
|
|
5
5
|
import { styleFor } from "./theme.js";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveBackground } from "./background.js";
|
|
7
|
+
import { emWidth, esc, fit, hash, num } from "./text.js";
|
|
7
8
|
import { fill, voiceOf } from "./locales.js";
|
|
8
|
-
/** The sizes by name. A phone's drawing is 390 wide and as tall as the bracket needs. */
|
|
9
|
+
/** 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. */
|
|
9
10
|
export const SIZES = Object.freeze({
|
|
10
11
|
wide: { width: 1200, height: 675 },
|
|
11
12
|
square: { width: 1080, height: 1080 },
|
|
13
|
+
card: { width: 1080, height: 1350 },
|
|
12
14
|
phone: { width: 390, height: null },
|
|
13
15
|
});
|
|
14
16
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
15
17
|
const isLayout = (x) => typeof x === "object" && x !== null && "ties" in x && "sides" in x && "rounds" in x;
|
|
18
|
+
/** The width of a text in the road look, set in a bold face: a little generous, so that it stays inside its place whichever font the page falls back to. */
|
|
19
|
+
const boldWidth = (text, size) => emWidth(text) * size;
|
|
20
|
+
/** The seed tag of a side: the tie's own, else the team's tag, else its seed number; `""` for none. */
|
|
21
|
+
function tagOf(tie, k) {
|
|
22
|
+
const given = tie.seeds?.[k];
|
|
23
|
+
const team = tie.sides[k];
|
|
24
|
+
const text = given ?? team?.tag ?? team?.seed;
|
|
25
|
+
return text === undefined || text === null ? "" : String(text).slice(0, 4);
|
|
26
|
+
}
|
|
27
|
+
/** The classes `tieClass` asks for on a tie's group, each a word of letters, digits, `-` and `_`; a leading space for each, `""` for none. A function that throws adds none. */
|
|
28
|
+
function tieClassOf(o, tie) {
|
|
29
|
+
if (typeof o.tieClass !== "function")
|
|
30
|
+
return "";
|
|
31
|
+
let asked;
|
|
32
|
+
try {
|
|
33
|
+
asked = o.tieClass(tie);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
if (typeof asked !== "string")
|
|
39
|
+
return "";
|
|
40
|
+
return asked.split(/\s+/).filter((word) => /^[A-Za-z_][\w-]*$/.test(word)).map((word) => ` ${word}`).join("");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Where a pill or a name of `w` wide can stand in the band `y0`..`y1` so that no connector line is within 5 of it: `pref` (its left edge) when that is clear, else the nearest clear place
|
|
44
|
+
* between the edges `lo` and `hi`; `null` when there is none.
|
|
45
|
+
*/
|
|
46
|
+
function freeX(segs, y0, y1, w, pref, lo, hi) {
|
|
47
|
+
const margin = 4;
|
|
48
|
+
const blocks = segs.filter((s) => Math.max(s.y0, s.y1) >= y0 - 1 && Math.min(s.y0, s.y1) <= y1 + 1).map((s) => [Math.min(s.x0, s.x1) - margin, Math.max(s.x0, s.x1) + margin]);
|
|
49
|
+
const ok = (x) => x >= lo && x + w <= hi && blocks.every(([a, b]) => x + w <= a || x >= b);
|
|
50
|
+
if (ok(pref))
|
|
51
|
+
return pref;
|
|
52
|
+
const found = [lo, hi - w, ...blocks.flatMap(([a, b]) => [b, a - w])].filter(ok);
|
|
53
|
+
return found.length === 0 ? null : found.reduce((best, x) => (Math.abs(x - pref) < Math.abs(best - pref) ? x : best));
|
|
54
|
+
}
|
|
55
|
+
/** The sizes of one row of a box: the name, the mark and the score. The road look has larger marks and a big, heavy score. */
|
|
56
|
+
function metrics(rowH, road) {
|
|
57
|
+
if (road)
|
|
58
|
+
return { font: clamp(rowH * 0.46, 9, 20), md: clamp(rowH * 0.8, 12, 56), score: clamp(rowH * 0.68, 11, 38) };
|
|
59
|
+
const font = clamp(rowH * 0.5, 8.5, 16);
|
|
60
|
+
return { font, md: clamp(rowH * 0.7, 10, 28), score: font + 1 };
|
|
61
|
+
}
|
|
62
|
+
/** The ties a team played on its way to the final: the tree's ties it is a side of, never the third-place match or a round-robin game. */
|
|
63
|
+
function pathOf(layout, team) {
|
|
64
|
+
const out = new Set();
|
|
65
|
+
if (team === undefined || team === null)
|
|
66
|
+
return out;
|
|
67
|
+
const id = String(team);
|
|
68
|
+
for (const t of layout.ties)
|
|
69
|
+
if (!t.third && t.round > 0 && t.sides.some((s) => s !== null && String(s.id) === id))
|
|
70
|
+
out.add(t.index);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
16
73
|
/** The layout with each team's name in the language: what the drawing, the table and the description are written from. */
|
|
17
74
|
export function localizedLayout(layout, v) {
|
|
18
75
|
const named = new Map(layout.teams.map((t) => [t.id, { ...t, name: v.team(t) }]));
|
|
@@ -42,21 +99,23 @@ export function drawBracket(input, options = {}) {
|
|
|
42
99
|
const theme = options.theme ?? "auto";
|
|
43
100
|
const sizeName = typeof options.size === "string" || options.size === undefined ? options.size ?? "wide" : null;
|
|
44
101
|
const size = sizeName ? SIZES[sizeName] : { width: options.size.width, height: options.size.height ?? null };
|
|
45
|
-
const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
102
|
+
const orientation = options.orientation && options.orientation !== "auto" ? options.orientation : sizeName === "phone" || sizeName === "card" || (size.height !== null && size.width < size.height * 0.8) ? "vertical" : "horizontal";
|
|
103
|
+
const road = options.look === "road";
|
|
104
|
+
const hasBg = typeof options.background === "string" || (typeof options.background === "object" && options.background !== null);
|
|
105
|
+
const prefix = options.id ?? `tb-${hash(JSON.stringify([v.locale.code, layout.teams.map((t) => t.name), layout.ties.map((t) => [t.id, t.sides.map((s) => s?.id ?? null), t.winner, t.result.score, t.round]), options.size, orientation, options.detail, theme, options.title, options.subtitle, options.seeds, options.scores, options.champion, options.markShape, options.roundLabels, options.thirdPlace, options.trophy ? 1 : 0, road ? 1 : 0, options.highlightPath ?? null, options.flags ? 1 : 0, ...(hasBg ? [options.background, options.vs, options.codes, options.seeds] : []), ...(options.flagHref ? ["h"] : [])]))}`;
|
|
106
|
+
const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: options.markShape ?? "round", look: road ? "road" : "classic", flags: options.flags, flagHref: options.flagHref, theme: theme === "light" ? "light" : theme === "dark" || theme === "poster" ? "dark" : "inherit", flagCache: new Map() };
|
|
107
|
+
const path = pathOf(layout, options.highlightPath);
|
|
108
|
+
const plan = planBracket(layout, options, v, size, orientation === "vertical", ctx, road, path);
|
|
109
|
+
const linked = { count: 0 };
|
|
110
|
+
const drawn = plan.boxes.map((b) => drawBox(b, plan, options, v, ctx, linked)); // draws the marks, so before the defs are written
|
|
50
111
|
const title = options.title ?? v.words.title;
|
|
51
112
|
const desc = describeBracket(layout, options);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme)}</style>`;
|
|
57
|
-
const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>`;
|
|
113
|
+
const picture = resolveBackground(options.background, theme, plan.width, plan.height, prefix);
|
|
114
|
+
const defs = markDefs(ctx) + (picture?.defs ?? "");
|
|
115
|
+
const style = theme === "none" ? "" : `<style>${styleFor(`.${prefix}`, theme, { road, links: linked.count > 0, follow: path.size > 0, bgAuto: picture?.auto }, picture?.inks)}</style>`;
|
|
116
|
+
const bg = options.background === false ? "" : `<rect class="tb-bg" width="${num(plan.width)}" height="${num(plan.height)}"${theme === "none" ? ' fill="none"' : ""}/>${picture?.body ?? ""}`;
|
|
58
117
|
return [
|
|
59
|
-
`<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen ${prefix}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(plan.width)} ${num(plan.height)}" width="${num(plan.width)}" height="${num(plan.height)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${prefix}-t ${prefix}-d">`,
|
|
118
|
+
`<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen ${prefix}${road ? " tb-look-road" : ""}${path.size > 0 ? " tb-follow" : ""}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(plan.width)} ${num(plan.height)}" width="${num(plan.width)}" height="${num(plan.height)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${prefix}-t ${prefix}-d">`,
|
|
60
119
|
`<title id="${prefix}-t">${esc(title)}</title><desc id="${prefix}-d">${esc(desc)}</desc>`,
|
|
61
120
|
style,
|
|
62
121
|
defs ? `<defs>${defs}</defs>` : "",
|
|
@@ -69,7 +128,7 @@ export function drawBracket(input, options = {}) {
|
|
|
69
128
|
].filter(Boolean).join("\n");
|
|
70
129
|
}
|
|
71
130
|
// ---------------------------------------------------------------------------------------------- the plan
|
|
72
|
-
function planBracket(layout, o, v, size, vertical, ctx) {
|
|
131
|
+
function planBracket(layout, o, v, size, vertical, ctx, road, path) {
|
|
73
132
|
const pad = vertical ? 14 : 22;
|
|
74
133
|
const showLabels = o.roundLabels ?? true;
|
|
75
134
|
const poster = o.theme === "poster";
|
|
@@ -77,6 +136,11 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
77
136
|
const links = [];
|
|
78
137
|
const texts = [];
|
|
79
138
|
const extras = [];
|
|
139
|
+
// The connector lines as they are drawn, so that the names of the rounds can be placed clear of them.
|
|
140
|
+
const segs = [];
|
|
141
|
+
const addLine = (...pts) => pts.slice(1).forEach(([x, y], i) => segs.push({ x0: pts[i][0], y0: pts[i][1], x1: x, y1: y }));
|
|
142
|
+
const clear = (lo, hi) => (w, pref, y) => freeX(segs, y - 16, y + 7, w, pref, lo, hi);
|
|
143
|
+
const finish = (plan) => ({ ...plan, texts: plan.texts.map((t) => (typeof t === "function" ? t() : t)) });
|
|
80
144
|
const W0 = size.width;
|
|
81
145
|
const titleH = (o.title ? (poster ? 46 : 38) : 0) + (o.subtitle ? 24 : 0) + (o.title || o.subtitle ? 10 : 0);
|
|
82
146
|
const hasGroups = layout.sides.some((s) => s.label);
|
|
@@ -102,6 +166,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
102
166
|
let others = [];
|
|
103
167
|
let ow = 0;
|
|
104
168
|
let extraH0 = 0;
|
|
169
|
+
let labelRoom = showLabels ? 24 : 6;
|
|
105
170
|
if (!vertical) {
|
|
106
171
|
const availW = W0 - 2 * pad;
|
|
107
172
|
const g = 0.2;
|
|
@@ -112,13 +177,16 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
112
177
|
D = clamp(d, 40, 220);
|
|
113
178
|
gapT = D * g;
|
|
114
179
|
centreT = Math.max(centreMin, D * 1.2);
|
|
115
|
-
|
|
180
|
+
// The names of the rounds head their columns: a name that cannot be one line in its column is set on two, which needs a taller strip over the boxes.
|
|
181
|
+
if (showLabels && layout.columns.some((c) => !nameFits(v, road, roundLabel(layout, c, o.roundNames, v), D + gapT * 0.8)))
|
|
182
|
+
labelRoom = 34;
|
|
183
|
+
const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
|
|
116
184
|
others = othersOf(layout);
|
|
117
185
|
ow = clamp(D * 1.1, 120, 200);
|
|
118
186
|
extraH0 = othersHeight(others.length, W0, pad, ow, OTHER_H) + thirdBlock;
|
|
119
187
|
const bodyH = size.height === null ? rows * 60 : size.height - top - pad - extraH0;
|
|
120
188
|
rowStep = bodyH / rows;
|
|
121
|
-
S = clamp(rowStep - 9, 24, 104);
|
|
189
|
+
S = clamp(rowStep - (road ? 7 : 9), 24, road ? 132 : 104);
|
|
122
190
|
rowStep = Math.max(rowStep, S + 6);
|
|
123
191
|
rowStep = Math.min(rowStep, S + 40);
|
|
124
192
|
boxW = D;
|
|
@@ -127,38 +195,102 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
127
195
|
else {
|
|
128
196
|
const availW = W0 - 2 * pad;
|
|
129
197
|
const slot = availW / Math.max(rows, 1);
|
|
130
|
-
S = clamp(slot - 5, 30, 150);
|
|
198
|
+
S = clamp(slot - 5, 30, road ? 340 : 150);
|
|
131
199
|
rowStep = Math.min(slot, S + 5);
|
|
132
200
|
boxW = S;
|
|
133
201
|
}
|
|
134
202
|
// Full boxes (names) only where the longest name fits beside its mark and a score.
|
|
135
|
-
const
|
|
203
|
+
const wide = boldWidth;
|
|
204
|
+
const longest = Math.max(0, ...layout.teams.map((t) => wide(t.name, 13)));
|
|
205
|
+
const longestAbbr = Math.max(0, ...layout.teams.map((t) => wide(abbrOf(t, v), 13)));
|
|
206
|
+
// Seed tags (the road look with `seeds`): a small pill beside each mark, or above it in a box of marks alone.
|
|
207
|
+
const tagsOn = road && (o.seeds ?? false) && layout.ties.some((t) => tagOf(t, 0) !== "" || tagOf(t, 1) !== "");
|
|
208
|
+
const tagW = tagsOn ? Math.max(...layout.ties.flatMap((t) => [tagOf(t, 0), tagOf(t, 1)]).map((x) => boldWidth(x, 8))) + 8 : 0;
|
|
209
|
+
// The road look's marks and scores are large: they are scaled down together until a row fits its box, before a name is dropped for its abbreviation.
|
|
210
|
+
const anyNotes = layout.ties.some((t) => noteParts(t, v).length > 0);
|
|
211
|
+
// The road look's boxes of marks alone carry each score under its mark, unless marks alone were asked for.
|
|
212
|
+
const markScores = road && o.detail !== "mark" && (o.scores ?? true) && layout.ties.some((t) => t.result.score !== null);
|
|
213
|
+
const fitOf = (w, h, named) => {
|
|
214
|
+
const rowH = (h - (anyNotes && h >= 62 ? 15 : 0)) / 2;
|
|
215
|
+
const { font, md, score } = metrics(rowH, road);
|
|
216
|
+
const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
|
|
217
|
+
const anyPens = layout.ties.some((t) => t.result.pens !== null);
|
|
218
|
+
const extra = (anyBye ? wide(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? wide("(00)", Math.max(score * 0.55, 8)) + 5 : 0);
|
|
219
|
+
const fixed = clamp(rowH * 0.16, 5, 12) + clamp(rowH * 0.18, 5, 10) + 8 + 10 + (tagW > 0 ? tagW + 3 : 0);
|
|
220
|
+
const varying = md + (named === "full" ? longest : longestAbbr) * (font / 13) + wide("00", score) + extra;
|
|
221
|
+
return Math.min(1, (w - fixed) / varying);
|
|
222
|
+
};
|
|
223
|
+
let fitNow = 1;
|
|
136
224
|
const pick = (w, h) => {
|
|
137
225
|
const rowH = h / 2;
|
|
138
|
-
const font =
|
|
139
|
-
const md = clamp(rowH * 0.7, 10, 28);
|
|
226
|
+
const { font, md, score } = metrics(rowH, false);
|
|
140
227
|
// Room for the score, and for the words some boxes carry beside it: a bye, a shoot-out's score.
|
|
141
228
|
const anyBye = layout.sides.some((sd) => sd.boxes.some((b) => b.byes.length > 0));
|
|
142
229
|
const anyPens = layout.ties.some((t) => t.result.pens !== null);
|
|
143
|
-
const extra = (anyBye ?
|
|
144
|
-
const needed = 6 + md + 5 + longest * (font / 13) + 8 +
|
|
230
|
+
const extra = (anyBye ? boldWidth(v.words.bye, Math.max(font - 2, 8)) + 8 : 0) + (anyPens ? boldWidth("(00)", Math.max(font - 2, 8)) + 3 : 0);
|
|
231
|
+
const needed = 6 + md + 5 + longest * (font / 13) + 8 + boldWidth("00", score) + extra + 14;
|
|
145
232
|
const named = o.detail && o.detail !== "auto" ? o.detail : w >= needed && h >= 36 ? "full" : w >= 76 && h >= 28 ? "compact" : "mark";
|
|
146
233
|
return named === "mark" && o.markShape === "none" ? "compact" : named;
|
|
147
234
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
235
|
+
// The road look: a box is as high as its width lets the large marks and scores be. A name is kept (full) only where that leaves rows of 28 or more;
|
|
236
|
+
// else the abbreviation (compact) where rows of 18 or more fit; else only the marks.
|
|
237
|
+
const codesOn = road && (o.codes ?? false);
|
|
238
|
+
const markCols = road && (markScores || tagsOn || codesOn || o.vs === true);
|
|
239
|
+
const anyPensAll = layout.ties.some((t) => t.result.pens !== null);
|
|
240
|
+
const roadPick = (w, hMax, grow = false) => {
|
|
241
|
+
const search = (named, hMin) => {
|
|
242
|
+
let h = hMax;
|
|
243
|
+
while (h > hMin && fitOf(w, h, named) < 0.94)
|
|
244
|
+
h -= 2;
|
|
245
|
+
h = Math.max(h, Math.min(hMin, hMax));
|
|
246
|
+
return { detail: named, h, ok: fitOf(w, h, named) >= 0.94 };
|
|
247
|
+
};
|
|
248
|
+
const forced = o.detail && o.detail !== "auto" ? o.detail : null;
|
|
249
|
+
let chosen = null;
|
|
250
|
+
if (forced === "full" || forced === "compact")
|
|
251
|
+
chosen = search(forced, forced === "full" ? 56 : 36);
|
|
252
|
+
else if (forced === null) {
|
|
253
|
+
const full = search("full", 56);
|
|
254
|
+
const compact = search("compact", 36);
|
|
255
|
+
chosen = full.ok ? full : compact.ok ? compact : o.markShape === "none" ? compact : null;
|
|
256
|
+
}
|
|
257
|
+
// A box of marks alone is as high as its marks, tags, codes and scores need; on a tall page it may be higher than the usual box, in a row it may not.
|
|
258
|
+
const m = Math.min((w - 12) / 2 - 3, 56);
|
|
259
|
+
const sf = clamp(m * 0.5, 9, 24);
|
|
260
|
+
const need = 10 + m + 3 + (markScores ? sf * 1.15 + (anyPensAll && m < 40 ? sf * 0.7 : 0) : 0) + (codesOn ? clamp(m * 0.24, 7, 11) * 1.25 : 0) + (tagsOn ? 12 : 0);
|
|
261
|
+
chosen ??= { detail: o.markShape === "none" ? "compact" : "mark", h: markCols ? Math.min(grow ? Math.max(hMax, need) : hMax, need) : Math.min(hMax, 2 * Math.min(w - 8, 60) + 8) };
|
|
262
|
+
if (chosen.detail !== "mark")
|
|
263
|
+
fitNow = Math.min(1, fitOf(w, chosen.h, chosen.detail));
|
|
264
|
+
return chosen;
|
|
265
|
+
};
|
|
266
|
+
let detail;
|
|
267
|
+
if (road) {
|
|
268
|
+
const picked = roadPick(boxW, vertical ? clamp(boxW * 0.42, 48, 124) : boxH, vertical);
|
|
269
|
+
detail = picked.detail;
|
|
270
|
+
if (vertical)
|
|
271
|
+
boxH = picked.h;
|
|
272
|
+
else {
|
|
273
|
+
S = picked.h;
|
|
274
|
+
boxH = S;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
detail = pick(boxW, vertical ? 48 : boxH);
|
|
279
|
+
if (vertical)
|
|
280
|
+
boxH = detail === "full" ? 50 : detail === "compact" ? 42 : boxW >= 76 ? 38 : 2 * Math.min(boxW - 8, 24) + 8;
|
|
281
|
+
}
|
|
282
|
+
// The names of the rounds stand on their own strip between the rounds, under the lines that join them: 6 for the bus, 20 for the pill, and room round both.
|
|
151
283
|
if (vertical) {
|
|
152
284
|
D = boxH;
|
|
153
|
-
gapT = showLabels ?
|
|
285
|
+
gapT = showLabels ? 32 : 16;
|
|
154
286
|
}
|
|
155
287
|
// The champion's block: a trophy, a large mark, the name and the word; and the third-place match below the final.
|
|
156
|
-
const champMark = vertical ? 54 : clamp(centreT * 0.34, 44, 84);
|
|
288
|
+
const champMark = vertical ? (road ? clamp(size.width * 0.14, 54, 96) : 54) : clamp(centreT * (road ? 0.4 : 0.34), 44, road ? 96 : 84);
|
|
157
289
|
const trophyH = hasTrophy ? (vertical ? 56 : clamp(centreT * 0.45, 48, 84)) : 0;
|
|
158
290
|
const nameFont = clamp(champMark * 0.3, 13, 20);
|
|
159
291
|
const champH = champ ? trophyH + (hasTrophy ? 6 : 0) + champMark + 8 + nameFont + 6 + 11 + 2 : 0;
|
|
160
292
|
const finalW = halves ? (vertical ? Math.min(size.width - 2 * pad, Math.max(boxW, 160)) : Math.min(centreT - 12, Math.max(D * 1.1, 120))) : D;
|
|
161
|
-
|
|
293
|
+
let finalH = vertical ? (detail === "mark" ? Math.max(boxH, 52) : boxH) : boxH;
|
|
162
294
|
const thirdH = withThird && !bottomThird ? 18 + boxH + 14 : 0;
|
|
163
295
|
// ----- the slot positions along t
|
|
164
296
|
if (!vertical) {
|
|
@@ -171,7 +303,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
171
303
|
sizes.forEach((sz) => { starts.push(t); t += sz + gapT; });
|
|
172
304
|
const width = Math.max(W0, total + 2 * pad);
|
|
173
305
|
// ----- span positions
|
|
174
|
-
const top = pad + titleH + (hasGroups ? 28 : 0) +
|
|
306
|
+
const top = pad + titleH + (hasGroups ? 28 : 0) + labelRoom;
|
|
175
307
|
const treeH = rows * rowStep;
|
|
176
308
|
// The final sits at the middle of the rows; the champion above it and the third place below need room.
|
|
177
309
|
const upNeed = (champ ? champH + 8 : 0) + 22 + finalH / 2;
|
|
@@ -201,14 +333,14 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
201
333
|
rects.set(layout.final.i, rect);
|
|
202
334
|
boxes.push({ rect, tie: layout.ties[layout.final.i], order: layout.final.order, bye: null, byes: [], final: true, champion: champ });
|
|
203
335
|
if (showLabels)
|
|
204
|
-
texts.push(label(v, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
|
|
336
|
+
texts.push(label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, rect.y - 7, "middle"));
|
|
205
337
|
if (champ)
|
|
206
338
|
extras.push(...championBlock(layout.champion, midX, rect.y - 22 - champH, champMark, trophyH, nameFont, o, v, ctx, poster));
|
|
207
339
|
if (withThird) {
|
|
208
340
|
const t3 = layout.ties[layout.third];
|
|
209
341
|
const r3 = { x: midX - finalW / 2, y: rect.y + rect.h + 14 + 18, w: finalW, h: boxH };
|
|
210
342
|
rects.set(layout.third, r3);
|
|
211
|
-
texts.push(label(v, v.words.thirdPlace, midX, r3.y - 6, "middle"));
|
|
343
|
+
texts.push(label(v, road, v.words.thirdPlace, midX, r3.y - 6, "middle"));
|
|
212
344
|
boxes.push({ rect: r3, tie: t3, order: [0, 1], bye: null, byes: [], final: false, champion: false });
|
|
213
345
|
}
|
|
214
346
|
}
|
|
@@ -229,7 +361,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
229
361
|
const ks = halves ? [slotOfColumn("l", ci), slotOfColumn("r", ci)] : [ci];
|
|
230
362
|
const used = layout.sides.length > 1 || !halves ? (halves ? [0, 1] : [0]) : [0];
|
|
231
363
|
ks.forEach((k, n) => { if (used.includes(n) && (!halves || n < layout.sides.length))
|
|
232
|
-
texts.push(label(v, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
|
|
364
|
+
texts.push(label(v, road, name, starts[k] + D / 2, top - 9, "middle", D + gapT * 0.8)); });
|
|
233
365
|
}
|
|
234
366
|
}
|
|
235
367
|
if (hasGroups) {
|
|
@@ -238,7 +370,7 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
238
370
|
return;
|
|
239
371
|
const ks = layout.columns.map((_, ci) => slotOfColumn(sd.key, ci));
|
|
240
372
|
const x0 = Math.min(...ks.map((k) => starts[k])), x1 = Math.max(...ks.map((k) => starts[k] + D));
|
|
241
|
-
texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(top - (showLabels ?
|
|
373
|
+
texts.push(`<text class="tb-half" x="${num((x0 + x1) / 2)}" y="${num(top - (showLabels ? labelRoom + 12 : 14))}" font-size="${poster ? 16 : 15}" font-weight="700" letter-spacing=".1em" text-anchor="middle">${esc(poster ? v.upper(sd.label) : sd.label)}</text>`);
|
|
242
374
|
});
|
|
243
375
|
}
|
|
244
376
|
// lines
|
|
@@ -253,26 +385,49 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
253
385
|
const x0 = goingRight ? a.x + a.w : a.x, x1 = goingRight ? b.x : b.x + b.w;
|
|
254
386
|
const y0 = a.y + a.h / 2, y1 = b.y + b.h / 2;
|
|
255
387
|
const xm = (x0 + x1) / 2;
|
|
256
|
-
|
|
388
|
+
addLine([x0, y0], [xm, y0], [xm, y1], [x1, y1]);
|
|
389
|
+
links.push(`<path class="${lineClass(path, t.index, p)}" d="M${num(x0)} ${num(y0)}H${num(xm)}V${num(y1)}H${num(x1)}"${lineAttrs(o)}/>`);
|
|
257
390
|
}
|
|
258
391
|
if (bottomThird)
|
|
259
|
-
thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, boxes, texts);
|
|
260
|
-
othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, boxes, texts);
|
|
392
|
+
thirdAtFoot(layout, height - pad - extraH0 + 6, width / 2, ow, OTHER_H, v, road, boxes, texts);
|
|
393
|
+
othersBlock(layout, others, height - pad - extraH0 + thirdBlock + 6, width, pad, ow, OTHER_H, v, road, boxes, texts);
|
|
261
394
|
texts.push(...headings(v, o, width, pad, poster));
|
|
262
|
-
return { width, height, boxes, links, texts, extras, detail, vertical };
|
|
395
|
+
return finish({ width, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn });
|
|
263
396
|
}
|
|
264
397
|
// ----- vertical: the rounds are rows. The left half is the top; the final is a band in the middle.
|
|
265
|
-
const bandT = D + gapT;
|
|
266
398
|
const groupBand = hasGroups ? 26 : 0;
|
|
267
399
|
// On a tall page the champion sits at the top, as the page's first thing; the lines into the final must not run through it.
|
|
268
400
|
const champTop = halves && champ ? champH + 14 : 0;
|
|
269
|
-
const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
|
|
270
401
|
const topT = pad + titleH + champTop + groupBand + (showLabels ? 14 : 0);
|
|
271
402
|
const bandsPerSide = nc;
|
|
272
|
-
const
|
|
403
|
+
const bandCount = halves ? 2 * bandsPerSide : nc + (champ ? 1 : 0);
|
|
273
404
|
others = othersOf(layout);
|
|
274
405
|
ow = Math.min(W0 - 2 * pad, 150);
|
|
275
|
-
const
|
|
406
|
+
const othersV = othersHeight(others.length, W0, pad, ow, OTHER_H);
|
|
407
|
+
// The height the page needs for boxes `h` high with `gap` between the rounds, and what it keeps for the final's band and the third-place match.
|
|
408
|
+
const needOf = (h, gap) => {
|
|
409
|
+
const finalHere = detail === "mark" ? Math.max(h, 52) : h;
|
|
410
|
+
const centre = (showLabels ? 32 : 0) + 6 + finalHere + 14;
|
|
411
|
+
const thirdV = bottomThird ? 18 + (road ? h : 52) + 16 : 0;
|
|
412
|
+
const band = h + gap;
|
|
413
|
+
return (halves ? topT + bandsPerSide * band + centre + bandsPerSide * band + groupBand + pad : topT + (nc + (champ ? 1 : 0)) * band + (champ ? champH : 0) + pad) + othersV + thirdV;
|
|
414
|
+
};
|
|
415
|
+
// The road look fits a tall page: the boxes are made lower until the rounds fit, or, with room to spare, the room left over is shared between the rounds.
|
|
416
|
+
if (road && size.height !== null) {
|
|
417
|
+
while (boxH > 40 && needOf(boxH, gapT) > size.height)
|
|
418
|
+
boxH -= 2;
|
|
419
|
+
const slack = size.height - needOf(boxH, gapT);
|
|
420
|
+
if (slack > 0 && bandCount > 0)
|
|
421
|
+
gapT += Math.min(slack / bandCount, boxH * 0.5);
|
|
422
|
+
D = boxH;
|
|
423
|
+
finalH = detail === "mark" ? Math.max(boxH, 52) : boxH;
|
|
424
|
+
}
|
|
425
|
+
const centreBand = (showLabels ? 32 : 0) + 6 + finalH + 14;
|
|
426
|
+
const thirdH2 = road ? boxH : 52;
|
|
427
|
+
const thirdBlockV = bottomThird ? 18 + thirdH2 + 16 : 0;
|
|
428
|
+
const bandT = D + gapT;
|
|
429
|
+
const totalT = needOf(boxH, gapT) - othersV - thirdBlockV;
|
|
430
|
+
const extraV = othersV + thirdBlockV;
|
|
276
431
|
const height = Math.max(size.height ?? 0, totalT + extraV);
|
|
277
432
|
const treeW = rows * rowStep;
|
|
278
433
|
const x0 = pad + (W0 - 2 * pad - treeW) / 2;
|
|
@@ -300,7 +455,8 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
300
455
|
if (champ)
|
|
301
456
|
extras.push(...championBlock(layout.champion, midX, pad + titleH + 4, champMark, trophyH, nameFont, o, v, ctx, poster));
|
|
302
457
|
if (showLabels) {
|
|
303
|
-
|
|
458
|
+
const at = y + 12;
|
|
459
|
+
texts.push(() => label(v, road, roundLabel(layout, layout.rounds, o.roundNames, v), midX, at, "middle", 1e9, clear(pad - 4, W0 - pad + 4)));
|
|
304
460
|
y += 16;
|
|
305
461
|
}
|
|
306
462
|
const rect = { x: midX - finalW / 2, y: y + 6, w: finalW, h: finalH };
|
|
@@ -315,8 +471,24 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
315
471
|
layout.columns.forEach((c, ci) => {
|
|
316
472
|
const name = roundLabel(layout, c, o.roundNames, v);
|
|
317
473
|
const sides = halves ? (layout.sides.length > 1 ? ["l", "r"] : ["l"]) : ["l"];
|
|
318
|
-
for (const sd of sides)
|
|
319
|
-
|
|
474
|
+
for (const sd of sides) {
|
|
475
|
+
const at = bandStart(sd, ci) - 8;
|
|
476
|
+
texts.push(() => {
|
|
477
|
+
if (road)
|
|
478
|
+
return label(v, road, name, pad, at, "start", 1e9, clear(pad - 4, W0 - pad + 4));
|
|
479
|
+
const shown = v.upper(name);
|
|
480
|
+
let size = 11, left = pad - 4;
|
|
481
|
+
for (const s of [11, 9.5, 8.5]) {
|
|
482
|
+
size = s;
|
|
483
|
+
const found = freeX(segs, at - 16, at + 7, boldWidth(shown, s) * 1.06 + 8, pad - 4, pad - 4, W0 - pad + 4);
|
|
484
|
+
if (found !== null) {
|
|
485
|
+
left = found;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return `<text class="tb-round tb-halo" x="${num(left + 4)}" y="${num(at)}" font-size="${size}" font-weight="600" letter-spacing=".08em">${esc(shown)}</text>`;
|
|
490
|
+
});
|
|
491
|
+
}
|
|
320
492
|
});
|
|
321
493
|
}
|
|
322
494
|
if (hasGroups) {
|
|
@@ -337,22 +509,132 @@ function planBracket(layout, o, v, size, vertical, ctx) {
|
|
|
337
509
|
const down = b.y > a.y;
|
|
338
510
|
const y0 = down ? a.y + a.h : a.y, y1 = down ? b.y : b.y + b.h;
|
|
339
511
|
const x0c = a.x + a.w / 2, x1c = b.x + b.w / 2;
|
|
340
|
-
|
|
341
|
-
|
|
512
|
+
// With the rounds named, the line turns just outside the box it leaves (going down) or just outside the box it enters (going up), so that the strip in front of the later round is free for its name.
|
|
513
|
+
const ym = showLabels ? (down ? y0 + 6 : y1 + 6) : down ? y0 + gapT * 0.4 : y0 - gapT * 0.4;
|
|
514
|
+
addLine([x0c, y0], [x0c, ym], [x1c, ym], [x1c, y1]);
|
|
515
|
+
links.push(`<path class="${lineClass(path, t.index, p)}" d="M${num(x0c)} ${num(y0)}V${num(ym)}H${num(x1c)}V${num(y1)}"${lineAttrs(o)}/>`);
|
|
342
516
|
}
|
|
343
517
|
if (bottomThird)
|
|
344
|
-
thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, 200),
|
|
345
|
-
othersBlock(layout, others, totalT - pad +
|
|
518
|
+
thirdAtFoot(layout, totalT - pad + 6, W0 / 2, Math.min(W0 - 2 * pad, road ? Math.max(boxW, 200) : 200), thirdH2, v, road, boxes, texts);
|
|
519
|
+
othersBlock(layout, others, totalT - pad + thirdBlockV + 6, W0, pad, ow, OTHER_H, v, road, boxes, texts);
|
|
346
520
|
texts.push(...headings(v, o, W0, pad, poster));
|
|
347
|
-
return { width: W0, height, boxes, links, texts, extras, detail, vertical };
|
|
521
|
+
return finish({ width: W0, height, boxes, links: orderLinks(links), texts, extras, detail, vertical, road, path, fit: fitNow, notes: anyNotes, markScores, markCols, tags: tagsOn, codes: codesOn });
|
|
522
|
+
}
|
|
523
|
+
/** A line's class: `tb-path` when both ties it joins are on the highlighted team's path. */
|
|
524
|
+
function lineClass(path, from, to) {
|
|
525
|
+
return path.has(from) && path.has(to) ? "tb-line tb-path" : "tb-line";
|
|
526
|
+
}
|
|
527
|
+
/** The lines of the highlighted path are drawn last, over the others. */
|
|
528
|
+
function orderLinks(links) {
|
|
529
|
+
return [...links.filter((l) => !l.includes("tb-path")), ...links.filter((l) => l.includes("tb-path"))];
|
|
348
530
|
}
|
|
349
531
|
const OTHER_H = 50;
|
|
350
532
|
const centreMinLtr = (d) => d * 1.1;
|
|
351
533
|
function lineAttrs(o) {
|
|
352
534
|
return o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".5"' : "";
|
|
353
535
|
}
|
|
354
|
-
|
|
355
|
-
|
|
536
|
+
/** The two lines a name is best broken into at a space (the widest line as narrow as it can be), or `null` for a name of one word. */
|
|
537
|
+
function twoLines(text, size, measure) {
|
|
538
|
+
const words = text.split(" ");
|
|
539
|
+
let best = null;
|
|
540
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
541
|
+
const pair = [words.slice(0, i).join(" "), words.slice(i).join(" ")];
|
|
542
|
+
if (best === null || Math.max(measure(pair[0], size), measure(pair[1], size)) < Math.max(measure(best[0], size), measure(best[1], size)))
|
|
543
|
+
best = pair;
|
|
544
|
+
}
|
|
545
|
+
return best;
|
|
546
|
+
}
|
|
547
|
+
/** The bold, letter-spaced width of a round's name (as a pill or a heading sets it). */
|
|
548
|
+
const nameWidth = (t, size) => boldWidth(t, size) * 1.06;
|
|
549
|
+
/**
|
|
550
|
+
* How a round's name is set within `maxWidth` (the column it heads): on one line at 11, smaller down to 9 (the pill's own room is 9 either side, or 4 where that is all there is), or, where it still does not
|
|
551
|
+
* fit, on two lines at 9, 8.5 and 8: a name is never cut while a smaller or a second line will hold it. The road look's pill and the classic look's heading take the same rule.
|
|
552
|
+
*/
|
|
553
|
+
function nameFits(v, road, text, maxWidth) {
|
|
554
|
+
const upper = v.upper(text);
|
|
555
|
+
return nameWidth(upper, 9) + (road ? 8 : 0) <= maxWidth;
|
|
556
|
+
}
|
|
557
|
+
function label(v, road, text, x, y, anchor, maxWidth = 1e9, place) {
|
|
558
|
+
const upper = v.upper(text);
|
|
559
|
+
if (!road) {
|
|
560
|
+
// A column's heading: 11 down to 9.5 on one line, then two lines at 9.5, 9 and 8.5; the last resort cuts it.
|
|
561
|
+
if (maxWidth < 1e9 && nameWidth(upper, 11) > maxWidth) {
|
|
562
|
+
let size = 11;
|
|
563
|
+
while (size > 9.5 && nameWidth(upper, size) > maxWidth)
|
|
564
|
+
size -= 0.5;
|
|
565
|
+
if (nameWidth(upper, size) <= maxWidth)
|
|
566
|
+
return `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="${size}" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(upper)}</text>`;
|
|
567
|
+
for (const s of [9.5, 9, 8.5]) {
|
|
568
|
+
const pair = twoLines(upper, s, nameWidth);
|
|
569
|
+
if (pair && Math.max(nameWidth(pair[0], s), nameWidth(pair[1], s)) <= maxWidth)
|
|
570
|
+
return pair.map((line, i) => `<text class="tb-round" x="${num(x)}" y="${num(y - (1 - i) * 10)}" font-size="${s}" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(line)}</text>`).join("");
|
|
571
|
+
}
|
|
572
|
+
return `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="8.5" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(fit(upper, 8.5, maxWidth, nameWidth))}</text>`;
|
|
573
|
+
}
|
|
574
|
+
const shown = upper;
|
|
575
|
+
const plain = `<text class="tb-round" x="${num(x)}" y="${num(y)}" font-size="11" font-weight="600" letter-spacing=".08em" text-anchor="${anchor}">${esc(shown)}</text>`;
|
|
576
|
+
if (!place)
|
|
577
|
+
return plain;
|
|
578
|
+
// The name is kept clear of the connector lines: where it cannot stand where it was put, it moves along its row.
|
|
579
|
+
for (const size of [11, 9.5, 8.5]) {
|
|
580
|
+
const w = nameWidth(shown, size) + 8;
|
|
581
|
+
const pref = anchor === "middle" ? x - w / 2 : x - 4;
|
|
582
|
+
const left = place(w, pref, y);
|
|
583
|
+
if (left === null)
|
|
584
|
+
continue;
|
|
585
|
+
return left === pref && size === 11 ? plain : `<text class="tb-round" x="${num(left + 4)}" y="${num(y)}" font-size="${size}" font-weight="600" letter-spacing=".08em" text-anchor="start">${esc(shown)}</text>`;
|
|
586
|
+
}
|
|
587
|
+
return plain;
|
|
588
|
+
}
|
|
589
|
+
// The road look names a round on a pill. Down to 9 on one line (with 9 either side), then a snugger pill (4 either side), then two lines at 9, 8.5 and 8.
|
|
590
|
+
let s = 11;
|
|
591
|
+
while (s > 9 && nameWidth(upper, s) > maxWidth - 18)
|
|
592
|
+
s -= 0.5;
|
|
593
|
+
let rows = [upper];
|
|
594
|
+
let side = 9;
|
|
595
|
+
if (nameWidth(upper, s) > maxWidth - 18) {
|
|
596
|
+
if (nameWidth(upper, s) <= maxWidth - 8)
|
|
597
|
+
side = 4;
|
|
598
|
+
else {
|
|
599
|
+
rows = [fit(upper, s, maxWidth - 12, nameWidth)];
|
|
600
|
+
for (const size of [9, 8.5, 8]) {
|
|
601
|
+
const pair = twoLines(upper, size, nameWidth);
|
|
602
|
+
if (pair && Math.max(nameWidth(pair[0], size), nameWidth(pair[1], size)) <= maxWidth - 12) {
|
|
603
|
+
rows = pair;
|
|
604
|
+
s = size;
|
|
605
|
+
side = 6;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
if (pair && size === 8) {
|
|
609
|
+
rows = pair.map((line) => fit(line, 8, maxWidth - 12, nameWidth));
|
|
610
|
+
s = 8;
|
|
611
|
+
side = 6;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const widest = Math.max(...rows.map((r) => nameWidth(r, s)));
|
|
617
|
+
let w = widest + 2 * side;
|
|
618
|
+
let x0 = anchor === "middle" ? x - w / 2 : x;
|
|
619
|
+
let fs = s;
|
|
620
|
+
if (place && rows.length === 1) {
|
|
621
|
+
// Where the lines leave no room, the name is set smaller (to 8) and the pill made snugger (side 6, then 4).
|
|
622
|
+
search: for (const size of s > 8 ? [s, 8.5, 8].filter((z) => z <= s) : [s]) {
|
|
623
|
+
for (const sd of [9, 6, 4]) {
|
|
624
|
+
w = nameWidth(rows[0], size) + 2 * sd;
|
|
625
|
+
const pref = anchor === "middle" ? x - w / 2 : x;
|
|
626
|
+
const found = place(w, pref, y);
|
|
627
|
+
x0 = found ?? pref;
|
|
628
|
+
fs = size;
|
|
629
|
+
if (found !== null)
|
|
630
|
+
break search;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const put = (line, by) => `<text class="tb-lab" x="${num(x0 + w / 2)}" y="${num(by)}" font-size="${num(fs)}" font-weight="700" letter-spacing=".08em" text-anchor="middle">${esc(line)}</text>`;
|
|
635
|
+
if (rows.length === 2)
|
|
636
|
+
return `<rect class="tb-panel" x="${num(x0)}" y="${num(y - 23.5)}" width="${num(w)}" height="29" rx="12"/>${put(rows[0], y - 12)}${put(rows[1], y - 1.5)}`;
|
|
637
|
+
return `<rect class="tb-panel" x="${num(x0)}" y="${num(y - 14.5)}" width="${num(w)}" height="20" rx="10"/>${put(rows[0], y)}`;
|
|
356
638
|
}
|
|
357
639
|
function headings(v, o, width, pad, poster) {
|
|
358
640
|
const out = [];
|
|
@@ -401,96 +683,200 @@ function othersHeight(count, width, pad, ow, oh) {
|
|
|
401
683
|
const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
|
|
402
684
|
return 26 + Math.ceil(count / cols) * (oh + 10) + 6;
|
|
403
685
|
}
|
|
404
|
-
function othersBlock(layout, list, y0, width, pad, ow, oh, v, boxes, texts) {
|
|
686
|
+
function othersBlock(layout, list, y0, width, pad, ow, oh, v, road, boxes, texts) {
|
|
405
687
|
if (!list.length)
|
|
406
688
|
return;
|
|
407
689
|
const cols = Math.max(1, Math.floor((width - 2 * pad + 10) / (ow + 10)));
|
|
408
690
|
const x0 = pad + (width - 2 * pad - (Math.min(cols, list.length) * (ow + 10) - 10)) / 2;
|
|
409
|
-
texts.push(label(v, v.words.other, width / 2, y0 + 12, "middle"));
|
|
691
|
+
texts.push(label(v, road, v.words.other, width / 2, y0 + 12, "middle"));
|
|
410
692
|
list.forEach((i, n) => {
|
|
411
693
|
const rect = { x: x0 + (n % cols) * (ow + 10), y: y0 + 24 + Math.floor(n / cols) * (oh + 10), w: ow, h: oh };
|
|
412
694
|
boxes.push({ rect, tie: layout.ties[i], order: [0, 1], bye: null, byes: [], final: false, champion: false });
|
|
413
695
|
});
|
|
414
696
|
}
|
|
415
697
|
/** The third-place match with its label, centred on `cx`, its label at `y0`. */
|
|
416
|
-
function thirdAtFoot(layout, y0, cx, w, h, v, boxes, texts) {
|
|
417
|
-
texts.push(label(v, v.words.thirdPlace, cx, y0 + 12, "middle"));
|
|
698
|
+
function thirdAtFoot(layout, y0, cx, w, h, v, road, boxes, texts) {
|
|
699
|
+
texts.push(label(v, road, v.words.thirdPlace, cx, y0 + 12, "middle"));
|
|
418
700
|
boxes.push({ rect: { x: cx - w / 2, y: y0 + 18, w, h }, tie: layout.ties[layout.third], order: [0, 1], bye: null, byes: [], final: false, champion: false });
|
|
419
701
|
}
|
|
420
|
-
|
|
421
|
-
function
|
|
702
|
+
/** The address `href` gives for a tie (or for one side of it), when it is one a drawing may link to; `null` otherwise. */
|
|
703
|
+
function hrefOf(o, tie, side) {
|
|
704
|
+
if (typeof o.href !== "function")
|
|
705
|
+
return null;
|
|
706
|
+
const url = side === undefined ? o.href(tie) : o.href(tie, side);
|
|
707
|
+
return typeof url === "string" && safeHref(url) ? url.trim() : null;
|
|
708
|
+
}
|
|
709
|
+
function drawBox(p, plan, o, v, ctx, linked) {
|
|
422
710
|
const { rect, tie } = p;
|
|
423
711
|
const { x, y, w, h } = rect;
|
|
424
712
|
const detail = plan.detail;
|
|
713
|
+
const road = plan.road;
|
|
425
714
|
const scoresOn = o.scores ?? true;
|
|
426
715
|
const seedsOn = o.seeds ?? false;
|
|
427
716
|
const highlight = o.winnerHighlight ?? true;
|
|
717
|
+
const description = describeTie(tie, v);
|
|
718
|
+
// A link on the whole box wins; with none, each side that has an address is a link on its own row.
|
|
719
|
+
const boxHref = hrefOf(o, tie);
|
|
720
|
+
const sideHref = (k) => (boxHref !== null || tie.sides[k] === null ? null : hrefOf(o, tie, k));
|
|
721
|
+
const anchor = (url, name, inner) => {
|
|
722
|
+
linked.count++;
|
|
723
|
+
return `<a class="tb-link" href="${esc(url)}" aria-label="${esc(name)}">${inner}</a>`;
|
|
724
|
+
};
|
|
428
725
|
const parts = [];
|
|
429
|
-
parts.push(`<rect class="tb-box" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(Math.min(7, h / 4))}"${o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".4"' : ""}/>`);
|
|
726
|
+
parts.push(`<rect class="tb-box" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(road ? Math.min(14, h / 3.2) : Math.min(7, h / 4))}"${boxHref !== null ? ' pointer-events="all"' : ""}${o.theme === "none" ? ' fill="none" stroke="currentColor" stroke-opacity=".4"' : ""}/>`);
|
|
430
727
|
const r = tie.result;
|
|
431
728
|
const notes = noteParts(tie, v);
|
|
432
|
-
|
|
729
|
+
// In the road look every box keeps the strip for the note when any tie has one, so that all the rows are the same size.
|
|
730
|
+
const noteH = detail !== "mark" && h >= (road ? 62 : 50) && (road ? plan.notes : notes.length > 0) ? (road ? 15 : 13) : 0;
|
|
433
731
|
const rowH = (h - noteH) / 2;
|
|
434
732
|
const rowsOrder = p.order;
|
|
435
733
|
const cls = (k) => (highlight && tie.winner !== null ? (tie.winner === k ? "tb-won" : "tb-lost") : "tb-pending");
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
const
|
|
734
|
+
const row = (k, inner, rx, ry, rw, rh) => {
|
|
735
|
+
const url = sideHref(k);
|
|
736
|
+
const team = tie.sides[k];
|
|
737
|
+
if (url === null || team === null)
|
|
738
|
+
return inner;
|
|
739
|
+
return anchor(url, `${team.name}: ${description}`, `<rect class="tb-hit" x="${num(rx)}" y="${num(ry)}" width="${num(rw)}" height="${num(rh)}" fill="transparent"/>${inner}`);
|
|
740
|
+
};
|
|
741
|
+
if (detail === "mark" && plan.markCols) {
|
|
742
|
+
// Two columns, each a stack: the seed tag, the mark, its three letters, the score. The marks are as large as the box lets the whole stack be, and every part is inside the box.
|
|
743
|
+
const padX = 6, padY = 5;
|
|
744
|
+
const colW = (w - 2 * padX) / 2;
|
|
745
|
+
const withScore = plan.markScores && scoresOn && r.score !== null;
|
|
746
|
+
const tags = [0, 1].map((k) => (plan.tags ? tagOf(tie, k) : ""));
|
|
747
|
+
const tagH = tags.some((t) => t !== "") ? 12 : 0;
|
|
748
|
+
const scoreOf = (k) => (withScore && r.score ? v.number(r.score[k]) : "");
|
|
749
|
+
const pensOf = (k) => (withScore && r.pens ? `(${v.number(r.pens[k])})` : "");
|
|
750
|
+
const hasPens = withScore && r.pens !== null;
|
|
751
|
+
const lay = (m) => {
|
|
752
|
+
const cf = clamp(m * 0.24, 7, 11);
|
|
753
|
+
let sf = clamp(m * 0.5, 9, 24);
|
|
754
|
+
const pf = (size) => Math.max(size * 0.55, 8);
|
|
755
|
+
// The score and the shoot-out's score share a line where they fit the column; else the shoot-out goes under the score.
|
|
756
|
+
const need = (size) => Math.max(...[0, 1].map((k) => boldWidth(scoreOf(k), size) + (pensOf(k) ? boldWidth(pensOf(k), pf(size)) + 3 : 0)));
|
|
757
|
+
if (withScore)
|
|
758
|
+
while (sf > 10 && need(sf) > colW - 2)
|
|
759
|
+
sf -= 0.5;
|
|
760
|
+
const inline = !withScore || need(sf) <= colW - 2;
|
|
761
|
+
const height = tagH + m + (plan.codes ? 3 + cf * 1.25 : 0) + (withScore ? 3 + sf * 1.15 + (hasPens && !inline ? pf(sf) * 1.15 : 0) : 0);
|
|
762
|
+
return { cf, sf, pf: pf(sf), inline, height };
|
|
763
|
+
};
|
|
764
|
+
let m = Math.max(10, Math.min(colW - 3, 56, h - 2 * padY));
|
|
765
|
+
let L = lay(m);
|
|
766
|
+
while (m > 10 && L.height > h - 2 * padY) {
|
|
767
|
+
m -= 0.5;
|
|
768
|
+
L = lay(m);
|
|
769
|
+
}
|
|
770
|
+
const top = y + (h - L.height) / 2;
|
|
771
|
+
const midY = top + tagH + m / 2;
|
|
772
|
+
rowsOrder.forEach((k, n) => {
|
|
773
|
+
const team = tie.sides[k];
|
|
774
|
+
const cx = x + padX + colW / 2 + n * colW;
|
|
775
|
+
const inner = [];
|
|
776
|
+
let yy = top;
|
|
777
|
+
if (tagH && tags[k]) {
|
|
778
|
+
const tw = boldWidth(tags[k], 8) + 8;
|
|
779
|
+
inner.push(`<rect class="tb-tagbox" x="${num(cx - tw / 2)}" y="${num(yy)}" width="${num(tw)}" height="11" rx="5.5"/><text class="tb-tag" x="${num(cx)}" y="${num(yy + 8.2)}" font-size="8" text-anchor="middle">${esc(tags[k])}</text>`);
|
|
780
|
+
}
|
|
781
|
+
yy += tagH;
|
|
782
|
+
inner.push(drawMark(team, cx, yy + m / 2, m, ctx));
|
|
783
|
+
yy += m;
|
|
784
|
+
if (plan.codes) {
|
|
785
|
+
yy += 3;
|
|
786
|
+
inner.push(`<text class="tb-code" x="${num(cx)}" y="${num(yy + L.cf * 0.75)}" font-size="${num(L.cf)}" text-anchor="middle">${esc(fit(abbrOf(team, v), L.cf, colW - 2, boldWidth))}</text>`);
|
|
787
|
+
yy += L.cf * 1.25;
|
|
788
|
+
}
|
|
789
|
+
if (withScore) {
|
|
790
|
+
yy += 3;
|
|
791
|
+
const pens = pensOf(k);
|
|
792
|
+
const span = pens && L.inline ? `<tspan class="tb-muted" dx="3" font-size="${num(L.pf)}" font-weight="600">${esc(pens)}</tspan>` : "";
|
|
793
|
+
inner.push(`<text class="tb-score" x="${num(cx)}" y="${num(yy + L.sf * 0.8)}" font-size="${num(L.sf)}" text-anchor="middle" font-variant-numeric="tabular-nums">${esc(scoreOf(k))}${span}</text>`);
|
|
794
|
+
yy += L.sf * 1.15;
|
|
795
|
+
if (pens && !L.inline)
|
|
796
|
+
inner.push(`<text class="tb-muted" x="${num(cx)}" y="${num(yy + L.pf * 0.8)}" font-size="${num(L.pf)}" font-weight="600" text-anchor="middle">${esc(pens)}</text>`);
|
|
797
|
+
}
|
|
798
|
+
parts.push(row(k, `<g class="${cls(k)}">${inner.join("")}</g>`, cx - colW / 2, y, colW, h));
|
|
799
|
+
});
|
|
800
|
+
// "vs" between the two marks, where they leave room for it.
|
|
801
|
+
const vf = clamp(m * 0.26, 7, 11);
|
|
802
|
+
if (o.vs === true && colW - m >= boldWidth(v.words.roadVs, vf) + 4)
|
|
803
|
+
parts.push(`<text class="tb-vs" x="${num(x + w / 2)}" y="${num(midY + vf * 0.35)}" font-size="${num(vf)}" text-anchor="middle">${esc(v.words.roadVs)}</text>`);
|
|
804
|
+
}
|
|
805
|
+
else if (detail === "mark") {
|
|
806
|
+
const stack = Math.min((h - 8) / 2, w - 8, road ? 60 : 44);
|
|
807
|
+
const side = Math.min(h - 8, (w - 12) / 2, road ? 60 : 44);
|
|
439
808
|
const stacked = stack >= side;
|
|
440
809
|
const md2 = Math.max(stacked ? stack : side, 8);
|
|
441
810
|
rowsOrder.forEach((k, n) => {
|
|
442
811
|
const team = tie.sides[k];
|
|
443
812
|
const cx = stacked ? x + w / 2 : x + w / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 3);
|
|
444
813
|
const cy = stacked ? y + h / 2 + (n === 0 ? -1 : 1) * (md2 / 2 + 1) : y + h / 2;
|
|
445
|
-
parts.push(`<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g
|
|
814
|
+
parts.push(row(k, `<g class="${cls(k)}">${drawMark(team, cx, cy, md2, ctx)}</g>`, cx - md2 / 2, cy - md2 / 2, md2, md2));
|
|
446
815
|
});
|
|
447
816
|
}
|
|
448
817
|
else {
|
|
449
|
-
const
|
|
450
|
-
const
|
|
818
|
+
const base = metrics(rowH, road);
|
|
819
|
+
const f = plan.fit;
|
|
820
|
+
const font = road ? Math.max(base.font * f, 8.5) : base.font, md = road ? base.md * f : base.md, scoreFont = road ? base.score * f : base.score;
|
|
821
|
+
const padX = road ? clamp(rowH * 0.16, 5, 12) : 6;
|
|
822
|
+
const gap = road ? clamp(rowH * 0.18, 5, 10) : 5;
|
|
823
|
+
const small = Math.max(road ? scoreFont * 0.55 : font - 2, 8);
|
|
824
|
+
const small2 = Math.max(font - 2, 8);
|
|
825
|
+
const mw = boldWidth;
|
|
826
|
+
const tf = clamp(font * 0.62, 7, 9.5);
|
|
451
827
|
rowsOrder.forEach((k, n) => {
|
|
452
828
|
const team = tie.sides[k];
|
|
453
829
|
const ry = y + n * rowH;
|
|
454
830
|
const cy = ry + rowH / 2;
|
|
455
831
|
const rowParts = [];
|
|
456
|
-
let tx = x +
|
|
832
|
+
let tx = x + padX;
|
|
457
833
|
if (o.markShape !== "none") {
|
|
458
|
-
rowParts.push(drawMark(team, x +
|
|
459
|
-
tx = x +
|
|
834
|
+
rowParts.push(drawMark(team, x + padX + md / 2, cy, md, ctx));
|
|
835
|
+
tx = x + padX + md + gap;
|
|
836
|
+
}
|
|
837
|
+
// The seed tag, a small pill between the mark and the name.
|
|
838
|
+
const tag = plan.tags ? tagOf(tie, k) : "";
|
|
839
|
+
if (tag) {
|
|
840
|
+
const tw = boldWidth(tag, tf) + 8;
|
|
841
|
+
rowParts.push(`<rect class="tb-tagbox" x="${num(tx)}" y="${num(cy - (tf + 4) / 2)}" width="${num(tw)}" height="${num(tf + 4)}" rx="${num((tf + 4) / 2)}"/><text class="tb-tag" x="${num(tx + tw / 2)}" y="${num(cy + tf * 0.35)}" font-size="${num(tf)}" text-anchor="middle">${esc(tag)}</text>`);
|
|
842
|
+
tx += tw + 3;
|
|
460
843
|
}
|
|
461
844
|
const score = scoresOn && r.score ? v.number(r.score[k]) : "";
|
|
462
845
|
const pensText = scoresOn && r.pens ? `(${v.number(r.pens[k])})` : "";
|
|
463
846
|
const bye = p.byes.includes(k);
|
|
464
|
-
const right = x + w -
|
|
465
|
-
const scoreW = score ?
|
|
466
|
-
const pensW = pensText ?
|
|
467
|
-
const seed = seedsOn && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
|
|
847
|
+
const right = x + w - padX;
|
|
848
|
+
const scoreW = score ? mw(score, scoreFont) : 0;
|
|
849
|
+
const pensW = pensText ? mw(pensText, small) + (road ? 4 : 3) : 0;
|
|
850
|
+
const seed = seedsOn && !road && team?.seed !== undefined ? `${v.number(team.seed)} ` : "";
|
|
468
851
|
const nm = team ? (detail === "full" ? team.name : abbrOf(team, v)) : v.words.tbd;
|
|
469
852
|
// The word for a bye goes beside the score only where the name still fits beside it.
|
|
470
|
-
const byeFits = bye && right - tx - (scoreW + pensW) - (
|
|
853
|
+
const byeFits = bye && right - tx - (scoreW + pensW) - (mw(v.words.bye, small2) + 6) - 6 >= mw(`${seed}${nm}`, font);
|
|
471
854
|
const byeText = byeFits ? v.words.bye : "";
|
|
472
|
-
const byeW = byeText ?
|
|
855
|
+
const byeW = byeText ? mw(byeText, small2) : 0;
|
|
473
856
|
const avail = right - tx - (scoreW + pensW) - (byeW ? byeW + 6 : 0) - 6;
|
|
474
857
|
// A name that does not fit is set a little smaller before it is cut.
|
|
475
858
|
let nameFont = font;
|
|
476
|
-
while (nameFont > 9.5 &&
|
|
859
|
+
while (nameFont > 9.5 && mw(`${seed}${nm}`, nameFont) > avail)
|
|
477
860
|
nameFont -= 0.5;
|
|
478
|
-
const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8));
|
|
861
|
+
const text = fit(`${seed}${nm}`, nameFont, Math.max(avail, 8), mw);
|
|
479
862
|
rowParts.push(`<text class="tb-name${team ? "" : " tb-muted"}" x="${num(tx)}" y="${num(cy + nameFont * 0.35)}" font-size="${num(nameFont)}">${esc(text)}</text>`);
|
|
480
863
|
if (score)
|
|
481
|
-
rowParts.push(`<text class="tb-score" x="${num(right)}" y="${num(cy +
|
|
864
|
+
rowParts.push(`<text class="tb-score" x="${num(right)}" y="${num(cy + scoreFont * 0.35)}" font-size="${num(scoreFont)}" text-anchor="end" font-variant-numeric="tabular-nums">${esc(score)}</text>`);
|
|
482
865
|
if (pensText)
|
|
483
|
-
rowParts.push(`<text class="tb-muted" x="${num(right - scoreW - 3)}" y="${num(cy +
|
|
866
|
+
rowParts.push(`<text class="tb-muted" x="${num(right - scoreW - (road ? 4 : 3))}" y="${num(cy + small * 0.35)}" font-size="${num(small)}" text-anchor="end">${esc(pensText)}</text>`);
|
|
484
867
|
if (byeText)
|
|
485
|
-
rowParts.push(`<text class="tb-muted" x="${num(right - (scoreW + pensW) - (scoreW || pensW ? 6 : 0))}" y="${num(cy +
|
|
486
|
-
parts.push(`<g class="${cls(k)}">${rowParts.join("")}</g
|
|
868
|
+
rowParts.push(`<text class="tb-muted" x="${num(right - (scoreW + pensW) - (scoreW || pensW ? 6 : 0))}" y="${num(cy + small2 * 0.35)}" font-size="${num(small2)}" font-style="italic" text-anchor="end">${esc(byeText)}</text>`);
|
|
869
|
+
parts.push(row(k, `<g class="${cls(k)}">${rowParts.join("")}</g>`, x, ry, w, rowH));
|
|
487
870
|
});
|
|
488
|
-
parts.push(`<line class="tb-line" x1="${num(x + 4)}" x2="${num(x + w - 4)}" y1="${num(y + rowH)}" y2="${num(y + rowH)}" stroke-opacity=".35"${o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
|
|
871
|
+
parts.push(`<line class="${road ? "tb-sep" : "tb-line"}" x1="${num(x + 4)}" x2="${num(x + w - 4)}" y1="${num(y + rowH)}" y2="${num(y + rowH)}" stroke-opacity=".35"${o.theme === "none" ? ' stroke="currentColor"' : ""}/>`);
|
|
489
872
|
if (noteH) {
|
|
490
|
-
const f = 9.5;
|
|
491
|
-
|
|
873
|
+
const f = road ? 9 : 9.5;
|
|
874
|
+
const line = road ? v.upper(notes.join(" · ")) : notes.join(" · ");
|
|
875
|
+
parts.push(`<text class="tb-muted${road ? " tb-note" : ""}" x="${num(x + w / 2)}" y="${num(y + h - (road ? 5 : 4))}" font-size="${f}"${road ? ' font-weight="700" letter-spacing=".05em"' : ""} text-anchor="middle">${esc(fit(line, f * (road ? 1.1 : 1), w - 8))}</text>`);
|
|
492
876
|
}
|
|
493
877
|
}
|
|
494
|
-
const
|
|
495
|
-
|
|
878
|
+
const extra = tieClassOf(o, tie);
|
|
879
|
+
const cls2 = `tb-tie${p.final ? " tb-final" : ""}${p.champion ? " tb-has-champ" : ""}${plan.path.has(tie.index) ? " tb-on-path" : ""}${extra}`;
|
|
880
|
+
const body = boxHref !== null ? anchor(boxHref, description, parts.join("")) : parts.join("");
|
|
881
|
+
return `<g class="${cls2}"><title>${esc(description)}</title>${body}</g>`;
|
|
496
882
|
}
|