@johnmorrisdotca/taisen-brackets 0.10.0 → 0.12.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 +27 -0
- package/README.md +12 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/locales.js +15 -0
- package/dist/rank-kit.d.ts +90 -0
- package/dist/rank-kit.js +126 -0
- package/dist/ranking-cards.d.ts +71 -0
- package/dist/ranking-cards.js +327 -0
- package/dist/ranking.d.ts +107 -0
- package/dist/ranking.js +391 -0
- package/dist/types.d.ts +23 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/ranking.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking lists for any ranked sport, drawn from the caller's data (a provincial or club feed, a federation's list): a title and a category, then the entries as a podium (the top
|
|
3
|
+
* three in gold, silver and bronze rows) above a compact list, as one featured row above two columns, or as a plain list. Text first: rank, flag, name, club, points and the
|
|
4
|
+
* movement since the last list, with no photo slot to leave empty. A champion row ("C"), an interim champion, newly ranked athletes ("NR"), tied ranks and paging are in.
|
|
5
|
+
* Static SVG for a given state, like every drawing here.
|
|
6
|
+
*/
|
|
7
|
+
import { arrow, boardFrame, boardWrap, bw, clampN, fitSize, label, pill } from "./rank-kit.js";
|
|
8
|
+
import { fill, voiceOf } from "./locales.js";
|
|
9
|
+
import { drawMark } from "./marks.js";
|
|
10
|
+
import { abbreviate, fit, num } from "./text.js";
|
|
11
|
+
/** The ranks of a list, in order: an entry's own `rank`, else its position, with equal points sharing a rank; `null` for a champion row. Pure; takes the list as it is ordered. */
|
|
12
|
+
export function rankingRanks(entries, options = {}) {
|
|
13
|
+
const ties = options.ties ?? true;
|
|
14
|
+
const out = [];
|
|
15
|
+
let seen = 0;
|
|
16
|
+
let last = null;
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
if (e.champion) {
|
|
19
|
+
out.push(null);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen += 1;
|
|
23
|
+
let rank = seen;
|
|
24
|
+
if (e.rank !== undefined)
|
|
25
|
+
rank = e.rank;
|
|
26
|
+
else if (ties && last && typeof e.points === "number" && last.points === e.points)
|
|
27
|
+
rank = last.rank;
|
|
28
|
+
out.push(rank);
|
|
29
|
+
last = { rank, points: e.points };
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** A category as a heading ("U18 · Men -60 kg"): an age group, a sex and a weight, any of them, joined by a dot. A weight is `-60` or `+100` (kg) or any text. */
|
|
34
|
+
export function rankingCategoryLabel(category, locale) {
|
|
35
|
+
const v = voiceFor(locale);
|
|
36
|
+
const w = v.words;
|
|
37
|
+
const parts = [];
|
|
38
|
+
if (category.age)
|
|
39
|
+
parts.push(category.age);
|
|
40
|
+
const sex = category.sex === "men" ? w.judoMen : category.sex === "women" ? w.judoWomen : undefined;
|
|
41
|
+
const kg = category.weight === undefined ? undefined : /^[-+]\d{2,3}$/.test(category.weight) ? fill(category.weight.startsWith("+") ? w.judoWeightOver : w.judoWeightClass, { sex: sex ?? "", w: category.weight.slice(1) }).trim() : [sex, category.weight].filter(Boolean).join(" ");
|
|
42
|
+
if (kg)
|
|
43
|
+
parts.push(kg);
|
|
44
|
+
else if (sex)
|
|
45
|
+
parts.push(sex);
|
|
46
|
+
return parts.join(" · ");
|
|
47
|
+
}
|
|
48
|
+
export const voiceFor = (locale) => voiceOf(locale === undefined ? {} : { locale });
|
|
49
|
+
export const nameText = (n, style, v) => {
|
|
50
|
+
if (typeof n === "string")
|
|
51
|
+
return n;
|
|
52
|
+
const given = (n.given ?? "").trim();
|
|
53
|
+
const sur = style === "as-is" ? n.surname.trim() : v.upper(n.surname.trim());
|
|
54
|
+
if (!given)
|
|
55
|
+
return sur;
|
|
56
|
+
return style === "given-first" || style === "as-is" ? `${given} ${sur}` : `${sur} ${given}`;
|
|
57
|
+
};
|
|
58
|
+
export const pointsText = (p, precision, v) => {
|
|
59
|
+
if (p === undefined || p === null)
|
|
60
|
+
return "";
|
|
61
|
+
if (typeof p === "string")
|
|
62
|
+
return p;
|
|
63
|
+
if (!Number.isFinite(p))
|
|
64
|
+
return "";
|
|
65
|
+
return precision === undefined ? v.number(p) : new Intl.NumberFormat(v.locale.tag, { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(p);
|
|
66
|
+
};
|
|
67
|
+
function validate(source, options) {
|
|
68
|
+
if (!source || !Array.isArray(source.entries))
|
|
69
|
+
throw new RangeError("taisen-brackets: a ranking needs a list of entries");
|
|
70
|
+
source.entries.forEach((e, i) => {
|
|
71
|
+
const nm = e?.name;
|
|
72
|
+
const ok = typeof nm === "string" ? nm.trim() !== "" : !!nm && typeof nm === "object" && typeof nm.surname === "string" && nm.surname.trim() !== "";
|
|
73
|
+
if (!ok)
|
|
74
|
+
throw new RangeError(`taisen-brackets: entry ${i + 1} of the ranking needs a name`);
|
|
75
|
+
if (e.rank !== undefined && !(Number.isInteger(e.rank) && e.rank >= 1))
|
|
76
|
+
throw new RangeError(`taisen-brackets: the rank of ${typeof nm === "string" ? nm : nm.surname} is a whole number from 1, not ${JSON.stringify(e.rank)}`);
|
|
77
|
+
if (e.change !== undefined && e.change !== "new" && !Number.isInteger(e.change))
|
|
78
|
+
throw new RangeError(`taisen-brackets: the change of ${typeof nm === "string" ? nm : nm.surname} is a whole number of places or "new", not ${JSON.stringify(e.change)}`);
|
|
79
|
+
if (e.points !== undefined && typeof e.points !== "number" && typeof e.points !== "string")
|
|
80
|
+
throw new RangeError(`taisen-brackets: the points of ${typeof nm === "string" ? nm : nm.surname} are a number or a text`);
|
|
81
|
+
});
|
|
82
|
+
const oneOf = (name, value, allowed) => {
|
|
83
|
+
if (value !== undefined && !allowed.includes(value))
|
|
84
|
+
throw new RangeError(`taisen-brackets: ${name} is ${allowed.map((a) => JSON.stringify(a)).join(", ")}, not ${JSON.stringify(value)}`);
|
|
85
|
+
};
|
|
86
|
+
oneOf("a ranking's style", options.style, ["podium", "list", "featured"]);
|
|
87
|
+
oneOf("movement", options.movement, ["arrow", "row", "both", "none"]);
|
|
88
|
+
oneOf("nameStyle", options.nameStyle, ["surname-first", "given-first", "as-is"]);
|
|
89
|
+
oneOf("rankStyle", options.rankStyle, ["plain", "hash"]);
|
|
90
|
+
oneOf("sort", options.sort, ["given", "points"]);
|
|
91
|
+
oneOf("columns", options.columns, [1, 2]);
|
|
92
|
+
if (options.big !== undefined && !(Number.isInteger(options.big) && options.big >= 0 && options.big <= 3))
|
|
93
|
+
throw new RangeError("taisen-brackets: big is a whole number from 0 to 3");
|
|
94
|
+
if (options.rows !== undefined && !(Number.isInteger(options.rows) && options.rows >= 1 && options.rows <= 100))
|
|
95
|
+
throw new RangeError("taisen-brackets: rows is a whole number from 1 to 100");
|
|
96
|
+
if (options.page !== undefined && !(Number.isInteger(options.page) && options.page >= 0))
|
|
97
|
+
throw new RangeError("taisen-brackets: page is a whole number from 0");
|
|
98
|
+
if (options.precision !== undefined && !(Number.isInteger(options.precision) && options.precision >= 0 && options.precision <= 6))
|
|
99
|
+
throw new RangeError("taisen-brackets: precision is a whole number from 0 to 6");
|
|
100
|
+
}
|
|
101
|
+
/** The entries in the order they are drawn, with their ranks. */
|
|
102
|
+
function ordered(source, options) {
|
|
103
|
+
const list = options.sort === "points"
|
|
104
|
+
? source.entries.map((e, i) => ({ e, i })).sort((a, b) => (a.e.champion ? 0 : 1) - (b.e.champion ? 0 : 1) || (typeof b.e.points === "number" ? b.e.points : -Infinity) - (typeof a.e.points === "number" ? a.e.points : -Infinity) || a.i - b.i).map((x) => x.e)
|
|
105
|
+
: [...source.entries];
|
|
106
|
+
const ranks = rankingRanks(list, options.ties === undefined ? {} : { ties: options.ties });
|
|
107
|
+
return list.map((e, i) => ({ e, rank: ranks[i] }));
|
|
108
|
+
}
|
|
109
|
+
/** The ranking as a sentence, the drawing's description. */
|
|
110
|
+
export function describeRanking(source, options = {}) {
|
|
111
|
+
const vv = voiceOf(options);
|
|
112
|
+
const w = vv.words;
|
|
113
|
+
const parts = [source.title ?? w.rankTitle];
|
|
114
|
+
if (source.category)
|
|
115
|
+
parts.push(source.category);
|
|
116
|
+
if (source.date)
|
|
117
|
+
parts.push(source.date);
|
|
118
|
+
const all = ordered(source, options);
|
|
119
|
+
const start = options.rows ? (options.page ?? 0) * options.rows : 0;
|
|
120
|
+
const shown = options.rows ? all.slice(start, start + options.rows) : all;
|
|
121
|
+
if (shown.length === 0)
|
|
122
|
+
parts.push(w.rankNone);
|
|
123
|
+
for (const { e, rank } of shown) {
|
|
124
|
+
const bits = [e.champion ? (e.champion === "interim" ? w.rankInterimLong : w.rankChampionLong) : String(rank), nameText(e.name, options.nameStyle ?? "surname-first", vv)];
|
|
125
|
+
const detail = [e.club, e.country].filter(Boolean).join(", ");
|
|
126
|
+
if (detail)
|
|
127
|
+
bits.push(detail);
|
|
128
|
+
const p = pointsText(e.points, options.precision, vv);
|
|
129
|
+
if (p)
|
|
130
|
+
bits.push(`${p} ${w.rankPoints}`);
|
|
131
|
+
if (e.change === "new")
|
|
132
|
+
bits.push(w.rankNewLong);
|
|
133
|
+
else if (typeof e.change === "number" && e.change !== 0)
|
|
134
|
+
bits.push(fill(e.change > 0 ? w.rankUp : w.rankDown, { n: vv.number(Math.abs(e.change)) }));
|
|
135
|
+
parts.push(bits.join(w.rankJoin));
|
|
136
|
+
}
|
|
137
|
+
return parts.join(w.sep) + w.end;
|
|
138
|
+
}
|
|
139
|
+
export const teamFor = (e, id, name, badge) => ({ id, name, abbr: badge, ...(e.flag ? { flag: e.flag } : {}), ...(e.mark ? { mark: e.mark } : {}) });
|
|
140
|
+
/**
|
|
141
|
+
* The ranking list as an SVG. Throws a `RangeError` for an entry with no name, a rank or change that is not a whole number, an option that is not one of its values, or a bad size or theme.
|
|
142
|
+
*/
|
|
143
|
+
export function rankingList(source, options = {}) {
|
|
144
|
+
validate(source, options);
|
|
145
|
+
const style = options.style ?? "podium";
|
|
146
|
+
const f = boardFrame(options, [source, style, options.big, options.columns, options.split, options.rows, options.page, options.medals, options.movement, options.nameStyle, options.rankStyle, options.precision, options.ties, options.sort, options.categories, options.badge]);
|
|
147
|
+
const { W, H, v } = f;
|
|
148
|
+
const w = v.words;
|
|
149
|
+
const unit = Math.min(W, H);
|
|
150
|
+
const nameStyle = options.nameStyle ?? "surname-first";
|
|
151
|
+
const movement = options.movement ?? "arrow";
|
|
152
|
+
const all = ordered(source, options);
|
|
153
|
+
const hasChampion = all.some((x) => x.e.champion);
|
|
154
|
+
const medals = options.medals ?? (style !== "list" && !hasChampion);
|
|
155
|
+
const columns = options.columns ?? (style === "featured" ? 2 : 1);
|
|
156
|
+
const page = options.page ?? 0;
|
|
157
|
+
const bigWanted = page > 0 ? 0 : options.big ?? (style === "podium" ? 3 : style === "featured" ? 1 : 0);
|
|
158
|
+
// The box the content is drawn in: as wide as a row reads well, centred.
|
|
159
|
+
const pad = Math.round(unit * 0.035);
|
|
160
|
+
const split = bigWanted > 0 && all.length - page * (options.rows ?? 0) > Math.min(bigWanted, all.length) && columns === 1 && W / H >= 1.45 && options.split !== false;
|
|
161
|
+
const boxW = Math.min(W - pad * 2, columns === 2 || split ? W - pad * 2 : unit * 1.3);
|
|
162
|
+
const bx = (W - boxW) / 2;
|
|
163
|
+
// The header.
|
|
164
|
+
const title = source.title ?? w.rankTitle;
|
|
165
|
+
const hs = clampN(unit * 0.05, 18, 70);
|
|
166
|
+
const cs = hs * 0.58;
|
|
167
|
+
let y = pad;
|
|
168
|
+
const body = [];
|
|
169
|
+
const dateText = source.date ?? "";
|
|
170
|
+
const dateW = dateText ? Math.min(boxW * 0.4, bw(dateText, cs) + 4) : 0;
|
|
171
|
+
const titleAvail = boxW - (dateW ? dateW + pad : 0);
|
|
172
|
+
const tsz = fitSize(title, hs, titleAvail, 0.5);
|
|
173
|
+
body.push(label(bx, y + hs * 0.85, tsz, fit(title, tsz, titleAvail + 1, bw), "rk-ink", "start", 800));
|
|
174
|
+
if (dateText)
|
|
175
|
+
body.push(label(bx + boxW, y + hs * 0.85, cs, fit(dateText, cs, dateW, bw), "rk-sub", "end", 700));
|
|
176
|
+
y += hs * 1.05;
|
|
177
|
+
const catText = source.category ?? "";
|
|
178
|
+
// The page's range sits on the second line at the right.
|
|
179
|
+
const total = all.length;
|
|
180
|
+
const perPage = options.rows;
|
|
181
|
+
const fromIdx = perPage ? page * perPage : 0;
|
|
182
|
+
if (catText) {
|
|
183
|
+
const p = pill(bx, y + cs * 0.7, cs * 1.35, catText, boxW * 0.62, "rk-accent", "rk-accentink");
|
|
184
|
+
body.push(p.svg);
|
|
185
|
+
}
|
|
186
|
+
y += catText ? cs * 1.35 + pad * 0.7 : pad * 0.35;
|
|
187
|
+
const footerH = source.source ? Math.max(14, unit * 0.026) + pad * 0.5 : 0;
|
|
188
|
+
const bodyTop = y;
|
|
189
|
+
const bodyBottom = H - pad - footerH;
|
|
190
|
+
const avail = bodyBottom - bodyTop;
|
|
191
|
+
// Which entries fit.
|
|
192
|
+
const cols = columns;
|
|
193
|
+
const minComp = Math.max(30, unit * 0.04);
|
|
194
|
+
const k = cols === 2 && style === "featured" ? 2.3 : 2.1;
|
|
195
|
+
const GAP = 0.14;
|
|
196
|
+
// How many entries fit when each compact row is at least `minComp` high.
|
|
197
|
+
const fitCount = (nBig) => nBig + cols * Math.max(1, Math.floor((split ? avail / minComp : avail / minComp - nBig * (k + GAP)) / (1 + GAP)));
|
|
198
|
+
const wanted = perPage ?? total - fromIdx;
|
|
199
|
+
const bigCap = Math.min(bigWanted, Math.max(0, total - fromIdx));
|
|
200
|
+
const count = Math.max(0, Math.min(wanted, total - fromIdx, perPage ? wanted : fitCount(bigCap)));
|
|
201
|
+
const slice = all.slice(fromIdx, fromIdx + count);
|
|
202
|
+
const nBig = Math.min(bigCap, slice.length);
|
|
203
|
+
const nComp = slice.length - nBig;
|
|
204
|
+
const rpc = Math.ceil(nComp / cols);
|
|
205
|
+
const denom = split ? rpc * (1 + GAP) : nBig * (k + GAP) + rpc * (1 + GAP);
|
|
206
|
+
const maxComp = unit * 0.11;
|
|
207
|
+
const compH = denom > 0 ? Math.min(maxComp, avail / denom) : maxComp;
|
|
208
|
+
// Side by side, the big rows stack in their own panel and take its whole height; stacked, they are `k` times a compact row.
|
|
209
|
+
const bigH = split ? Math.min(unit * 0.27, avail / (nBig * (1 + GAP))) : compH * k;
|
|
210
|
+
const gap = compH * GAP;
|
|
211
|
+
const bigGap = split ? bigH * GAP : gap;
|
|
212
|
+
const leftW = split ? Math.round((boxW - pad) * 0.44) : boxW;
|
|
213
|
+
// Rows, worked out.
|
|
214
|
+
const marksAny = options.badge === true || slice.some((x) => x.e.flag || x.e.mark);
|
|
215
|
+
const rows = slice.map(({ e, rank }, i) => {
|
|
216
|
+
const name = nameText(e.name, nameStyle, v);
|
|
217
|
+
const medal = medals && rank !== null && rank <= 3 ? rank : e.champion ? 1 : 0;
|
|
218
|
+
const rankText = e.champion ? (e.champion === "interim" ? w.rankInterim : w.rankChampion) : `${options.rankStyle === "hash" ? "#" : ""}${v.number(rank ?? 0)}`;
|
|
219
|
+
const second = [e.club ?? e.country, options.categories ? e.category : undefined].filter((x) => !!x).join(" · ");
|
|
220
|
+
const badge = abbreviate(e.club ?? name).slice(0, 3);
|
|
221
|
+
return { e, rank, rankText, name, second, code: e.code ?? "", points: pointsText(e.points, options.precision, v), medal, change: e.change, team: teamFor(e, `r${fromIdx + i}`, name, badge) };
|
|
222
|
+
});
|
|
223
|
+
const bigRows = rows.slice(0, nBig), compRows = rows.slice(nBig);
|
|
224
|
+
const bigM = metrics(f, bigRows, bigH, true, movement, marksAny, leftW);
|
|
225
|
+
const colGap = pad;
|
|
226
|
+
const colW = split ? boxW - leftW - pad : cols === 2 ? (boxW - colGap) / 2 : boxW;
|
|
227
|
+
const compM = metrics(f, compRows, compH, false, movement, marksAny, colW);
|
|
228
|
+
let cy = bodyTop;
|
|
229
|
+
bigRows.forEach((row) => {
|
|
230
|
+
drawRow(body, f, { x: bx, y: cy, w: leftW, h: bigH }, row, bigM, true, movement);
|
|
231
|
+
cy += bigH + bigGap;
|
|
232
|
+
});
|
|
233
|
+
const compTop = split ? bodyTop : cy;
|
|
234
|
+
const compX = split ? bx + leftW + pad : bx;
|
|
235
|
+
compRows.forEach((row, i) => {
|
|
236
|
+
const col = Math.floor(i / rpc), idx = i % rpc;
|
|
237
|
+
drawRow(body, f, { x: compX + col * (colW + colGap), y: compTop + idx * (compH + gap), w: colW, h: compH }, row, compM, false, movement);
|
|
238
|
+
});
|
|
239
|
+
if (rows.length === 0)
|
|
240
|
+
body.push(label(bx, bodyTop + hs, cs, w.rankNone, "rk-sub", "start", 700));
|
|
241
|
+
// The range at the right of the category line, where entries are paged.
|
|
242
|
+
if (perPage && total > perPage && rows.length) {
|
|
243
|
+
const t = fill(w.rankRange, { a: v.number(fromIdx + 1), b: v.number(fromIdx + rows.length), n: v.number(total) });
|
|
244
|
+
body.push(label(bx + boxW, bodyTop - pad * 0.7 - cs * 0.3, cs * 0.85, fit(t, cs * 0.85, boxW * 0.35, bw), "rk-sub", "end", 700));
|
|
245
|
+
}
|
|
246
|
+
if (source.source) {
|
|
247
|
+
const fs = Math.max(12, unit * 0.022);
|
|
248
|
+
body.push(label(bx, H - pad - fs * 0.2, fs, fit(source.source, fs, boxW, bw), "rk-sub", "start", 600));
|
|
249
|
+
}
|
|
250
|
+
const desc = describeRanking(source, options);
|
|
251
|
+
return boardWrap(f, options.title ?? [title, source.category].filter(Boolean).join(": "), desc, body, "ranking");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The widths of the columns a class of rows shares, so that the ranks, arrows, flags and points line up down the list. Where the row is narrow for them the type steps down until the
|
|
255
|
+
* name has room, and the code (which only repeats the flag) goes first.
|
|
256
|
+
*/
|
|
257
|
+
function metrics(f, rows, h, big, movement, marksAny, rowW) {
|
|
258
|
+
const v = f.v;
|
|
259
|
+
const he = Math.min(h, rowW / (big ? 4.4 : 7.2));
|
|
260
|
+
const showMove = movement !== "none" && rows.some((r) => r.change !== undefined);
|
|
261
|
+
const moveText = (r) => (r.change === "new" ? v.words.rankNew : typeof r.change === "number" && r.change !== 0 ? v.number(Math.abs(r.change)) : "");
|
|
262
|
+
const longest = Math.max(0, ...rows.map((r) => bw(r.name, he * (big ? 0.34 : 0.4) * 0.62)));
|
|
263
|
+
const need = Math.min(rowW * 0.42, longest);
|
|
264
|
+
let m;
|
|
265
|
+
for (const sc of [1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52]) {
|
|
266
|
+
const e = he * sc;
|
|
267
|
+
const pad = e * 0.2;
|
|
268
|
+
const rs = e * (big ? 0.56 : 0.5);
|
|
269
|
+
const rankW = Math.max(e * 0.7, ...rows.map((r) => bw(r.rankText, rs))) + e * 0.2;
|
|
270
|
+
const ms = e * (big ? 0.26 : 0.3);
|
|
271
|
+
const moveW = showMove ? Math.max(ms * 1.4, ...rows.map((r) => (r.change === "new" ? bw(moveText(r), ms * 0.9) + ms * 0.9 : r.change === undefined ? 0 : ms * 1.1 + ms * 0.35 + bw(moveText(r), ms)))) + e * 0.16 : 0;
|
|
272
|
+
const d = e * (big ? 0.52 : 0.6);
|
|
273
|
+
const markW = marksAny ? d + e * 0.2 : 0;
|
|
274
|
+
const ps = e * 0.4;
|
|
275
|
+
const pointsRaw = Math.max(0, ...rows.map((r) => (r.points ? bw(r.points, ps) : 0)));
|
|
276
|
+
const bigLabel = big && pointsRaw > 0 ? bw(v.upper(v.words.rankPoints), Math.max(ps * 0.3, 10)) : 0;
|
|
277
|
+
const suffixW = !big && pointsRaw > 0 ? bw(v.words.rankPointsShort, Math.max(ps * 0.5, 10)) + ps * 0.3 : 0;
|
|
278
|
+
const pointsW = Math.max(pointsRaw + suffixW, bigLabel);
|
|
279
|
+
const cs = e * 0.3;
|
|
280
|
+
const codeW = !big && rows.some((r) => r.code) ? Math.max(...rows.map((r) => bw(r.code, cs))) + e * 0.2 : 0;
|
|
281
|
+
const ns = e * (big ? 0.34 : 0.4);
|
|
282
|
+
const ss = e * (big ? 0.19 : 0.26);
|
|
283
|
+
const fixed = pad * 2 + rankW + moveW + markW + pointsW + (big ? pad * 1.8 : pad * 0.8);
|
|
284
|
+
const nameCol = Math.min(Math.max(0, ...rows.map((r) => bw(r.name, ns))) + pad, rowW);
|
|
285
|
+
m = { he: e, rs, rankW, ms, moveW, d, markW, ps, pointsW, ns, ss, cs, codeW: 0, pad, nameCol };
|
|
286
|
+
if (rowW - fixed >= need) {
|
|
287
|
+
if (rowW - fixed - codeW >= need * 1.3)
|
|
288
|
+
m.codeW = codeW;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return m;
|
|
293
|
+
}
|
|
294
|
+
function drawRow(out, f, r, row, m, big, movement) {
|
|
295
|
+
const { v } = f;
|
|
296
|
+
const w = v.words;
|
|
297
|
+
const g = [];
|
|
298
|
+
const tint = row.medal ? `rk-medal${row.medal}` : "";
|
|
299
|
+
const mv = typeof row.change === "number" && row.change !== 0 ? (row.change > 0 ? "up" : "down") : "";
|
|
300
|
+
const tintMove = (movement === "row" || movement === "both") && mv && !row.medal ? `rk-m${mv}` : "";
|
|
301
|
+
const rx = Math.min(r.h * 0.18, 18);
|
|
302
|
+
g.push(`<rect class="${["rk-row", tint, tintMove].filter(Boolean).join(" ")}" x="${num(r.x)}" y="${num(r.y)}" width="${num(r.w)}" height="${num(r.h)}" rx="${num(rx)}"${row.medal ? ` fill="url(#${f.scope}-g${row.medal})"` : ""}/>`);
|
|
303
|
+
const cy = r.y + r.h / 2;
|
|
304
|
+
let x = r.x + m.pad;
|
|
305
|
+
const right = r.x + r.w - m.pad;
|
|
306
|
+
// The rank.
|
|
307
|
+
const rankCls = row.medal ? `rk-num${row.medal}` : "rk-ink";
|
|
308
|
+
g.push(label(x + (m.rankW - m.pad * 0.2) / 2, cy + m.rs * 0.36, m.rs, row.rankText, rankCls, "middle", 800));
|
|
309
|
+
x += m.rankW;
|
|
310
|
+
// The movement: an arrow and the number of places, or NR.
|
|
311
|
+
if (m.moveW > 0) {
|
|
312
|
+
if (row.change === "new") {
|
|
313
|
+
const p = pill(x, cy, m.ms * 1.35, w.rankNew, m.moveW, "rk-nr", "rk-nrink");
|
|
314
|
+
g.push(p.svg);
|
|
315
|
+
}
|
|
316
|
+
else if (mv && (movement === "arrow" || movement === "both" || movement === "row")) {
|
|
317
|
+
const n = v.number(Math.abs(row.change));
|
|
318
|
+
if (movement !== "row") {
|
|
319
|
+
g.push(arrow(x + m.ms * 0.55, cy, m.ms * 0.85, mv === "up"));
|
|
320
|
+
g.push(label(x + m.ms * 1.1 + m.ms * 0.3, cy + m.ms * 0.36, m.ms, n, mv === "up" ? "rk-up" : "rk-down", "start", 800));
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
g.push(label(x, cy + m.ms * 0.36, m.ms, `${mv === "up" ? "+" : "−"}${n}`, mv === "up" ? "rk-up" : "rk-down", "start", 800));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else if (row.change === 0) {
|
|
327
|
+
g.push(label(x + m.ms * 0.55, cy + m.ms * 0.36, m.ms, "–", "rk-sub", "middle", 800));
|
|
328
|
+
}
|
|
329
|
+
x += m.moveW;
|
|
330
|
+
}
|
|
331
|
+
// The flag, a mark, or initials.
|
|
332
|
+
if (m.markW > 0) {
|
|
333
|
+
g.push(drawMark(row.team, x + m.d / 2, cy, m.d, f.ctx));
|
|
334
|
+
x += m.markW;
|
|
335
|
+
}
|
|
336
|
+
// The points, at the right.
|
|
337
|
+
let nameRight = right;
|
|
338
|
+
if (m.pointsW > 0 && row.points) {
|
|
339
|
+
if (big) {
|
|
340
|
+
const sepX = right - m.pointsW - m.pad * 0.9;
|
|
341
|
+
g.push(`<line class="rk-sep" x1="${num(sepX)}" x2="${num(sepX)}" y1="${num(r.y + r.h * 0.2)}" y2="${num(r.y + r.h * 0.8)}"/>`);
|
|
342
|
+
const ps = fitSize(row.points, m.ps, m.pointsW, 0.5);
|
|
343
|
+
const ls = Math.max(m.ps * 0.3, 10);
|
|
344
|
+
g.push(label(right - m.pointsW / 2, cy + ps * 0.2, ps, row.points, "rk-ink", "middle", 800));
|
|
345
|
+
g.push(label(right - m.pointsW / 2, cy + ps * 0.2 + ls * 1.6, ls, fit(v.upper(w.rankPoints), ls, m.pointsW, bw), "rk-sub", "middle", 700, ' letter-spacing=".12em"'));
|
|
346
|
+
nameRight = sepX - m.pad * 0.9;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const suffix = w.rankPointsShort;
|
|
350
|
+
const sz = Math.max(m.ps * 0.5, 10);
|
|
351
|
+
const sw = bw(suffix, sz);
|
|
352
|
+
g.push(label(right, cy + sz * 0.36, sz, suffix, "rk-sub", "end", 700));
|
|
353
|
+
g.push(label(right - sw - m.ps * 0.3, cy + m.ps * 0.36, m.ps, row.points, "rk-ink", "end", 800));
|
|
354
|
+
nameRight = right - m.pointsW - m.pad * 0.8;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (m.pointsW > 0) {
|
|
358
|
+
nameRight = right - m.pointsW - m.pad * 0.8;
|
|
359
|
+
}
|
|
360
|
+
// The code, then the name and what goes under or beside it.
|
|
361
|
+
let codeX = nameRight;
|
|
362
|
+
if (m.codeW > 0 && !big) {
|
|
363
|
+
codeX = nameRight - m.codeW;
|
|
364
|
+
if (row.code)
|
|
365
|
+
g.push(label(codeX + m.codeW, cy + m.cs * 0.36, m.cs, row.code, "rk-sub", "end", 700));
|
|
366
|
+
nameRight = codeX - m.pad * 0.3;
|
|
367
|
+
}
|
|
368
|
+
const avail = Math.max(m.ns, nameRight - x);
|
|
369
|
+
const twoLine = big || m.he >= 42;
|
|
370
|
+
if (twoLine && row.second) {
|
|
371
|
+
const ns = fitSize(row.name, m.ns, avail, 0.5);
|
|
372
|
+
const ss = fitSize(row.second, m.ss, avail, 0.6);
|
|
373
|
+
const block = ns * 1.05 + ss * 1.3;
|
|
374
|
+
const top = cy - block / 2;
|
|
375
|
+
g.push(label(x, top + ns * 0.86, ns, fit(row.name, ns, avail + 1, bw), "rk-ink", "start", 800));
|
|
376
|
+
g.push(label(x, top + ns * 1.05 + ss * 1.05, ss, fit(row.second, ss, avail + 1, bw), "rk-sub", "start", 600));
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const club = row.second;
|
|
380
|
+
const nameAvail0 = Math.min(avail, m.nameCol + m.pad);
|
|
381
|
+
const clubW = club && avail - nameAvail0 > m.he * 3 ? avail - nameAvail0 : 0;
|
|
382
|
+
const nameAvail = clubW ? nameAvail0 : avail;
|
|
383
|
+
const ns = fitSize(row.name, m.ns, nameAvail, 0.55);
|
|
384
|
+
g.push(label(x, cy + ns * 0.36, ns, fit(row.name, ns, nameAvail + 1, bw), "rk-ink", "start", 800));
|
|
385
|
+
if (clubW) {
|
|
386
|
+
const ss = m.ss * 1.05;
|
|
387
|
+
g.push(label(x + nameAvail, cy + ss * 0.36, ss, fit(club, ss, clubW + 1, bw), "rk-sub", "start", 600));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
out.push(`<g class="rk-entry">${g.join("")}</g>`);
|
|
391
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -397,6 +397,29 @@ export interface Words {
|
|
|
397
397
|
/** The default titles of the contest card and of the mat queue board. */
|
|
398
398
|
judoCardTitle: string;
|
|
399
399
|
judoQueueTitle: string;
|
|
400
|
+
/** Ranking lists (`rankingList`): the heading, "points" and its short form, the champion's mark ("C") and its words, the interim champion's, newly ranked ("NR") and its words, the movement in a sentence (`{n}` places), no change, which entries are shown (`{a}`, `{b}`, `{n}`) and an empty list. */
|
|
401
|
+
rankTitle: string;
|
|
402
|
+
rankPoints: string;
|
|
403
|
+
rankPointsShort: string;
|
|
404
|
+
rankChampion: string;
|
|
405
|
+
rankChampionLong: string;
|
|
406
|
+
rankInterim: string;
|
|
407
|
+
rankInterimLong: string;
|
|
408
|
+
rankNew: string;
|
|
409
|
+
rankNewLong: string;
|
|
410
|
+
rankUp: string;
|
|
411
|
+
rankDown: string;
|
|
412
|
+
rankSame: string;
|
|
413
|
+
rankRange: string;
|
|
414
|
+
rankNone: string;
|
|
415
|
+
/** What joins the pieces of one entry in the description ("1, NAKAMURA Kenji, 412.50 points"). */
|
|
416
|
+
rankJoin: string;
|
|
417
|
+
/** Number-one cards (`rankingNumberOnes`): the heading, and `{category}`: "#1 in -57 kg". The entry list (`rankingEntryList`): the heading, the column heading over the rankings, and the mark of an athlete with none. */
|
|
418
|
+
rankNumberOnes: string;
|
|
419
|
+
rankNumberOneIn: string;
|
|
420
|
+
rankEntryTitle: string;
|
|
421
|
+
rankColRanking: string;
|
|
422
|
+
rankUnranked: string;
|
|
400
423
|
}
|
|
401
424
|
/** A language: its words, the names of the rounds (by their English names), and the tag `Intl` formats numbers and dates for. */
|
|
402
425
|
export interface Locale {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.12.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.12.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/taisen-brackets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Taisen Brackets 対戦, knockout brackets as SVG: from a list of ties (games, best-of series, two legs, replays, byes, a third-place match) it works out the rounds and halves and draws the bracket with the halves meeting at the final, or left to right, wide, square, as a tall card or for a phone, in a classic or a road look (large round flags, big scores, a team's dotted path, a link on every box), in light, dark or a gold-on-navy poster look, in eight layouts (the tree, rows, shields, a score-update card, tall columns, the NBA's play-in, a series card and group tables), on one of twenty-five poster backgrounds (the playing surfaces of five sports among them) or your own, with each team's logo or round flag, the champion in the centre, and an accessible table; and the road to the final of either finalist. NHL, NBA, MLB, NFL, CFL, MLS and World Cup formats by era, in English, French, Japanese, Spanish and German. Zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bracket",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@eslint/js": "^9.0.0",
|
|
120
120
|
"@johnmorrisdotca/dougu-sports": "0.1.0",
|
|
121
|
-
"@johnmorrisdotca/hata": "1.
|
|
121
|
+
"@johnmorrisdotca/hata": "1.6.2",
|
|
122
122
|
"@playwright/test": "^1.63.0",
|
|
123
123
|
"@types/node": "^24.0.0",
|
|
124
124
|
"eslint": "^9.0.0",
|