@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two more drawings from a ranking's data. `rankingNumberOnes`: the number one of each category (each weight class, each age group) as a card, in a grid: name, club or country, flag,
|
|
3
|
+
* points and "#1 in -57 kg", no photo. `rankingEntryList`: an event's entry list, in the order of the draw, with each athlete's ranking beside the name (the seeding view).
|
|
4
|
+
* Static SVG for a given state, like every drawing here.
|
|
5
|
+
*/
|
|
6
|
+
import { boardFrame, boardWrap, bw, clampN, fitSize, label, pill } from "./rank-kit.js";
|
|
7
|
+
import { inkOn } from "./judo-sheets.js";
|
|
8
|
+
import { fill, voiceOf } from "./locales.js";
|
|
9
|
+
import { drawMark } from "./marks.js";
|
|
10
|
+
import { abbreviate, esc, fit, num } from "./text.js";
|
|
11
|
+
import { nameText, pointsText, teamFor } from "./ranking.js";
|
|
12
|
+
function checkEntry(e, where) {
|
|
13
|
+
const n = e?.name;
|
|
14
|
+
const ok = typeof n === "string" ? n.trim() !== "" : !!n && typeof n === "object" && typeof n.surname === "string" && n.surname.trim() !== "";
|
|
15
|
+
if (!ok)
|
|
16
|
+
throw new RangeError(`taisen-brackets: ${where} needs a name`);
|
|
17
|
+
if (e.rank !== undefined && !(Number.isInteger(e.rank) && e.rank >= 1))
|
|
18
|
+
throw new RangeError(`taisen-brackets: the ranking of ${nameOf(e)} is a whole number from 1`);
|
|
19
|
+
}
|
|
20
|
+
const nameOf = (e) => (typeof e.name === "string" ? e.name : e.name.surname);
|
|
21
|
+
/** Orange, crimson, blue, teal, purple and amber, each dark enough for white type (4.5 to 1 or better). */
|
|
22
|
+
export const NUMBER_ONE_COLORS = Object.freeze(["#c2410c", "#be123c", "#1d4ed8", "#0f766e", "#6d28d9", "#a16207"]);
|
|
23
|
+
const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
24
|
+
/** The cards as a sentence, the drawing's description. */
|
|
25
|
+
export function describeNumberOnes(source, options = {}) {
|
|
26
|
+
const v = voiceOf(options);
|
|
27
|
+
const w = v.words;
|
|
28
|
+
const parts = [source.title ?? w.rankNumberOnes];
|
|
29
|
+
if (source.date)
|
|
30
|
+
parts.push(source.date);
|
|
31
|
+
for (const c of source.cards) {
|
|
32
|
+
const bits = [fill(w.rankNumberOneIn, { category: c.category }), nameText(c.entry.name, options.nameStyle ?? "surname-first", v)];
|
|
33
|
+
const detail = c.entry.club ?? c.entry.country;
|
|
34
|
+
if (detail)
|
|
35
|
+
bits.push(detail);
|
|
36
|
+
const p = pointsText(c.entry.points, options.precision, v);
|
|
37
|
+
if (p)
|
|
38
|
+
bits.push(`${p} ${w.rankPoints}`);
|
|
39
|
+
parts.push(bits.join(w.rankJoin));
|
|
40
|
+
}
|
|
41
|
+
return parts.join(w.sep) + w.end;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The number ones as cards in a grid: a coloured panel with the 1, the flag, the name and the club or country, the points and a pill that says "#1 in -57 kg". Throws a `RangeError` for
|
|
45
|
+
* no cards, a card with no category, an entry with no name, a colour that is not a hex code, columns that are not 1 to 4, or a bad size or theme.
|
|
46
|
+
*/
|
|
47
|
+
export function rankingNumberOnes(source, options = {}) {
|
|
48
|
+
if (!source || !Array.isArray(source.cards) || source.cards.length === 0)
|
|
49
|
+
throw new RangeError("taisen-brackets: the number ones need at least one card");
|
|
50
|
+
source.cards.forEach((c, i) => {
|
|
51
|
+
if (typeof c?.category !== "string" || c.category.trim() === "")
|
|
52
|
+
throw new RangeError(`taisen-brackets: card ${i + 1} needs a category`);
|
|
53
|
+
checkEntry(c.entry, `the entry of card ${i + 1}`);
|
|
54
|
+
});
|
|
55
|
+
if (options.columns !== undefined && !(Number.isInteger(options.columns) && options.columns >= 1 && options.columns <= 4))
|
|
56
|
+
throw new RangeError("taisen-brackets: columns is a whole number from 1 to 4");
|
|
57
|
+
const colors = options.colors ?? NUMBER_ONE_COLORS;
|
|
58
|
+
if (colors.length === 0 || colors.some((c) => !HEX.test(c)))
|
|
59
|
+
throw new RangeError("taisen-brackets: colors are hex codes such as #c2410c");
|
|
60
|
+
const f = boardFrame(options, [source, options.columns, options.nameStyle, options.precision, colors]);
|
|
61
|
+
const { W, H, v } = f;
|
|
62
|
+
const w = v.words;
|
|
63
|
+
const unit = Math.min(W, H);
|
|
64
|
+
const pad = Math.round(unit * 0.035);
|
|
65
|
+
const body = [];
|
|
66
|
+
const header = heading(f, source.title ?? w.rankNumberOnes, undefined, source.date, pad, Math.min(W - pad * 2, unit * 1.45), source.source);
|
|
67
|
+
body.push(...header.svg);
|
|
68
|
+
const boxW = header.boxW, bx = header.bx;
|
|
69
|
+
const n = source.cards.length;
|
|
70
|
+
const gap = Math.round(unit * 0.02);
|
|
71
|
+
const avail = header.bottom - header.top;
|
|
72
|
+
const nameStyle = options.nameStyle ?? "surname-first";
|
|
73
|
+
const texts = source.cards.map((c) => ({ name: nameText(c.entry.name, nameStyle, v), points: pointsText(c.entry.points, options.precision, v), pill: fill(w.rankNumberOneIn, { category: c.category }) }));
|
|
74
|
+
// How many columns: the one that lets the longest name be set biggest (cards must be at least 3.4 times as wide as high, so the type has room).
|
|
75
|
+
let best = { c: 1, ch: 0, cw: 0, size: -1 };
|
|
76
|
+
for (let c = options.columns ?? 1; c <= (options.columns ?? Math.min(4, n)); c++) {
|
|
77
|
+
const rows = Math.ceil(n / c);
|
|
78
|
+
const cw = (boxW - (c - 1) * gap) / c;
|
|
79
|
+
const ch = Math.min((avail - (rows - 1) * gap) / rows, cw / 3.4, unit * 0.24);
|
|
80
|
+
const size = cardType(cw, ch, texts).name;
|
|
81
|
+
if (size > best.size + 0.5 || (Math.abs(size - best.size) <= 0.5 && ch > best.ch))
|
|
82
|
+
best = { c, ch, cw, size };
|
|
83
|
+
}
|
|
84
|
+
const { c: cols, ch, cw } = best;
|
|
85
|
+
const top = header.top;
|
|
86
|
+
const m = cardType(cw, ch, texts);
|
|
87
|
+
source.cards.forEach((card, i) => {
|
|
88
|
+
const col = i % cols, row = Math.floor(i / cols);
|
|
89
|
+
const r = { x: bx + col * (cw + gap), y: top + row * (ch + gap), w: cw, h: ch };
|
|
90
|
+
body.push(drawCard(f, r, card, colors[i % colors.length], nameStyle, options.precision, i, m));
|
|
91
|
+
});
|
|
92
|
+
return boardWrap(f, options.title ?? (source.title ?? w.rankNumberOnes), describeNumberOnes(source, options), body, "number-ones");
|
|
93
|
+
}
|
|
94
|
+
function cardType(cw, ch, texts) {
|
|
95
|
+
const he = Math.min(ch, cw / 3.4);
|
|
96
|
+
const pad = he * 0.14;
|
|
97
|
+
const pw = he * 0.62;
|
|
98
|
+
const d = he * 0.38;
|
|
99
|
+
const ps = he * 0.22;
|
|
100
|
+
const pillH = he * 0.15;
|
|
101
|
+
const rightW = Math.max(...texts.map((t) => Math.max(bw(t.points, ps), bw(t.pill, pillH * 0.56) + pillH * 0.7)));
|
|
102
|
+
const nameAvail = cw - pw - pad * 2.4 - d - pad - rightW - pad;
|
|
103
|
+
const longest = Math.max(...texts.map((t) => bw(t.name, 1)));
|
|
104
|
+
const name = Math.max(1, Math.min(he * 0.24, nameAvail / longest));
|
|
105
|
+
return { he, pw, pad, d, ps, pillH, rightW, nameAvail, name, sub: Math.min(he * 0.14, name * 0.62) };
|
|
106
|
+
}
|
|
107
|
+
function drawCard(f, r, card, color, nameStyle, precision, i, m) {
|
|
108
|
+
const { v } = f;
|
|
109
|
+
const w = v.words;
|
|
110
|
+
const g = [];
|
|
111
|
+
const e = card.entry;
|
|
112
|
+
const name = nameText(e.name, nameStyle, v);
|
|
113
|
+
const second = e.club ?? e.country ?? "";
|
|
114
|
+
const points = pointsText(e.points, precision, v);
|
|
115
|
+
const rx = Math.min(r.h * 0.12, 22);
|
|
116
|
+
g.push(`<rect class="rk-row" x="${num(r.x)}" y="${num(r.y)}" width="${num(r.w)}" height="${num(r.h)}" rx="${num(rx)}"/>`);
|
|
117
|
+
// The number panel: rounded on the left, square on the right.
|
|
118
|
+
const ink = inkOn(color) === "#ffffff" ? "rk-onaccent" : "rk-onaccent-dark";
|
|
119
|
+
g.push(`<rect x="${num(r.x)}" y="${num(r.y)}" width="${num(m.pw)}" height="${num(r.h)}" rx="${num(rx)}" style="fill:${esc(color)}"/>`);
|
|
120
|
+
g.push(`<rect x="${num(r.x + m.pw - rx)}" y="${num(r.y)}" width="${num(rx)}" height="${num(r.h)}" style="fill:${esc(color)}"/>`);
|
|
121
|
+
const ns1 = m.he * 0.5;
|
|
122
|
+
g.push(label(r.x + m.pw / 2, r.y + r.h / 2 + ns1 * 0.36, ns1, v.number(1), ink, "middle", 800));
|
|
123
|
+
let x = r.x + m.pw + m.pad * 1.2;
|
|
124
|
+
const right = r.x + r.w - m.pad * 1.2;
|
|
125
|
+
// Points and the pill, at the right.
|
|
126
|
+
const pillText = fill(w.rankNumberOneIn, { category: card.category });
|
|
127
|
+
const stackH = (points ? m.ps * 1.1 : 0) + m.pillH;
|
|
128
|
+
let y = r.y + (r.h - stackH) / 2;
|
|
129
|
+
if (points) {
|
|
130
|
+
g.push(label(right - m.rightW / 2, y + m.ps * 0.82, m.ps, points, "rk-ink", "middle", 800));
|
|
131
|
+
y += m.ps * 1.1;
|
|
132
|
+
}
|
|
133
|
+
g.push(pill(right, y + m.pillH / 2, m.pillH, pillText, m.rightW, "rk-pill", "rk-pillink", "end").svg);
|
|
134
|
+
// The flag and the name.
|
|
135
|
+
if (e.flag || e.mark) {
|
|
136
|
+
g.push(drawMark(teamFor(e, `c${i}`, name, abbreviate(e.club ?? name)), x + m.d / 2, r.y + r.h / 2, m.d, f.ctx));
|
|
137
|
+
x += m.d + m.pad;
|
|
138
|
+
}
|
|
139
|
+
const avail = right - m.rightW - m.pad - x;
|
|
140
|
+
const ns = Math.min(m.name, fitSize(name, m.name, avail, 0.9));
|
|
141
|
+
if (second) {
|
|
142
|
+
const ss = Math.min(m.sub, fitSize(second, m.sub, avail, 0.9));
|
|
143
|
+
const block = ns * 1.05 + ss * 1.4;
|
|
144
|
+
const top = r.y + r.h / 2 - block / 2;
|
|
145
|
+
g.push(label(x, top + ns * 0.86, ns, fit(name, ns, avail + 1, bw), "rk-ink", "start", 800));
|
|
146
|
+
g.push(label(x, top + ns * 1.05 + ss * 1.1, ss, fit(second, ss, avail + 1, bw), "rk-sub", "start", 600));
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
g.push(label(x, r.y + r.h / 2 + ns * 0.36, ns, fit(name, ns, avail + 1, bw), "rk-ink", "start", 800));
|
|
150
|
+
return `<g class="rk-card">${g.join("")}</g>`;
|
|
151
|
+
}
|
|
152
|
+
/** The entry list as a sentence, the drawing's description. */
|
|
153
|
+
export function describeEntryList(source, options = {}) {
|
|
154
|
+
const v = voiceOf(options);
|
|
155
|
+
const w = v.words;
|
|
156
|
+
const parts = [source.title ?? w.rankEntryTitle];
|
|
157
|
+
if (source.category)
|
|
158
|
+
parts.push(source.category);
|
|
159
|
+
if (source.date)
|
|
160
|
+
parts.push(source.date);
|
|
161
|
+
const list = entryOrder(source, options.sort);
|
|
162
|
+
list.forEach((e, i) => {
|
|
163
|
+
const bits = [v.number(i + 1), nameText(e.name, options.nameStyle ?? "surname-first", v)];
|
|
164
|
+
const detail = e.club ?? e.country;
|
|
165
|
+
if (detail)
|
|
166
|
+
bits.push(detail);
|
|
167
|
+
bits.push(e.rank === undefined ? w.rankUnranked : `${w.rankColRanking} ${v.number(e.rank)}`);
|
|
168
|
+
parts.push(bits.join(w.rankJoin));
|
|
169
|
+
});
|
|
170
|
+
return parts.join(w.sep) + w.end;
|
|
171
|
+
}
|
|
172
|
+
const entryOrder = (source, sort) => sort === "ranking" ? source.entries.map((e, i) => ({ e, i })).sort((a, b) => (a.e.rank ?? Infinity) - (b.e.rank ?? Infinity) || a.i - b.i).map((x) => x.e) : [...source.entries];
|
|
173
|
+
/**
|
|
174
|
+
* An event's entry list: a numbered row for each athlete in the order of the draw, with the flag, the name and club, the country's letters and the athlete's ranking (`NR` for none) at the right. Throws a
|
|
175
|
+
* `RangeError` for an entry with no name, a ranking that is not a whole number from 1, an unknown option value or a bad size or theme.
|
|
176
|
+
*/
|
|
177
|
+
export function rankingEntryList(source, options = {}) {
|
|
178
|
+
if (!source || !Array.isArray(source.entries))
|
|
179
|
+
throw new RangeError("taisen-brackets: an entry list needs a list of entries");
|
|
180
|
+
source.entries.forEach((e, i) => checkEntry(e, `entry ${i + 1} of the entry list`));
|
|
181
|
+
if (options.sort !== undefined && options.sort !== "entry" && options.sort !== "ranking")
|
|
182
|
+
throw new RangeError(`taisen-brackets: sort is "entry" or "ranking", not ${JSON.stringify(options.sort)}`);
|
|
183
|
+
if (options.columns !== undefined && !(Number.isInteger(options.columns) && options.columns >= 1 && options.columns <= 3))
|
|
184
|
+
throw new RangeError("taisen-brackets: columns is a whole number from 1 to 3");
|
|
185
|
+
if (options.rows !== undefined && !(Number.isInteger(options.rows) && options.rows >= 1 && options.rows <= 200))
|
|
186
|
+
throw new RangeError("taisen-brackets: rows is a whole number from 1 to 200");
|
|
187
|
+
if (options.page !== undefined && !(Number.isInteger(options.page) && options.page >= 0))
|
|
188
|
+
throw new RangeError("taisen-brackets: page is a whole number from 0");
|
|
189
|
+
const f = boardFrame(options, [source, options.sort, options.numbers, options.columns, options.rows, options.page, options.nameStyle, options.precision, options.badge]);
|
|
190
|
+
const { W, H, v } = f;
|
|
191
|
+
const w = v.words;
|
|
192
|
+
const unit = Math.min(W, H);
|
|
193
|
+
const pad = Math.round(unit * 0.035);
|
|
194
|
+
const nameStyle = options.nameStyle ?? "surname-first";
|
|
195
|
+
const all = entryOrder(source, options.sort);
|
|
196
|
+
const body = [];
|
|
197
|
+
const title = source.title ?? w.rankEntryTitle;
|
|
198
|
+
const header = heading(f, title, source.category, source.date, pad, W - pad * 2, source.source);
|
|
199
|
+
body.push(...header.svg);
|
|
200
|
+
const { bx, boxW } = header;
|
|
201
|
+
const avail = header.bottom - header.top - unit * 0.04;
|
|
202
|
+
const minRow = Math.max(28, unit * 0.034);
|
|
203
|
+
const gap = 0.12;
|
|
204
|
+
const colGap = pad;
|
|
205
|
+
// Columns: the fewest that fit every entry at a readable row height, within what the width allows.
|
|
206
|
+
const maxCols = Math.max(1, Math.min(3, Math.floor(boxW / (unit * 0.55))));
|
|
207
|
+
let cols = options.columns ?? 1;
|
|
208
|
+
// A wide board takes two columns so that a row is not a long way from its number to its ranking; a short list stays in one.
|
|
209
|
+
if (options.columns === undefined) {
|
|
210
|
+
cols = Math.min(maxCols, Math.ceil(boxW / (unit * 1.25)), Math.max(1, Math.ceil(all.length / 6)));
|
|
211
|
+
while (cols < maxCols && Math.ceil(all.length / cols) * minRow * (1 + gap) > avail)
|
|
212
|
+
cols += 1;
|
|
213
|
+
}
|
|
214
|
+
const perCol = Math.max(1, Math.floor(avail / (minRow * (1 + gap))));
|
|
215
|
+
const page = options.page ?? 0;
|
|
216
|
+
const per = options.rows ?? perCol * cols;
|
|
217
|
+
const from = Math.min(page * per, Math.max(0, all.length - 1));
|
|
218
|
+
const slice = all.slice(from, from + per);
|
|
219
|
+
const rpc = Math.ceil(slice.length / cols);
|
|
220
|
+
const h = Math.min(unit * 0.085, avail / (Math.max(1, rpc) * (1 + gap)));
|
|
221
|
+
const colW = (boxW - (cols - 1) * colGap) / cols;
|
|
222
|
+
const marksAny = options.badge === true || slice.some((e) => e.flag || e.mark);
|
|
223
|
+
const numbers = options.numbers ?? true;
|
|
224
|
+
const he = Math.min(h, colW / 8);
|
|
225
|
+
const cs = he * 0.3;
|
|
226
|
+
const hasCode = slice.some((e) => e.code);
|
|
227
|
+
const hasPoints = slice.some((e) => e.points !== undefined);
|
|
228
|
+
const rs = he * 0.46;
|
|
229
|
+
const rankTexts = slice.map((e) => (e.rank === undefined ? w.rankUnranked : v.number(e.rank)));
|
|
230
|
+
const rankW = Math.max(bw(w.rankColRanking, cs * 0.85), ...rankTexts.map((t) => bw(t, rs))) + he * 0.2;
|
|
231
|
+
const ptsS = he * 0.3;
|
|
232
|
+
const ptsTexts = slice.map((e) => pointsText(e.points, options.precision, v));
|
|
233
|
+
const ptsW = hasPoints ? Math.max(...ptsTexts.map((t) => bw(t, ptsS))) + he * 0.3 : 0;
|
|
234
|
+
const codeW = hasCode ? Math.max(...slice.map((e) => bw(e.code ?? "", cs))) + he * 0.2 : 0;
|
|
235
|
+
const numW = numbers ? Math.max(he * 0.7, bw(v.number(from + slice.length), he * 0.36)) : 0;
|
|
236
|
+
const d = he * 0.58;
|
|
237
|
+
const rowPad = he * 0.2;
|
|
238
|
+
// The column heading over the ranking.
|
|
239
|
+
const hs = clampN(cs * 0.85, 10, 40);
|
|
240
|
+
for (let c = 0; c < cols; c++) {
|
|
241
|
+
const x1 = bx + c * (colW + colGap) + colW - rowPad;
|
|
242
|
+
body.push(label(x1 - (rankW - he * 0.2) / 2, header.top + hs * 0.8, hs, fit(v.upper(w.rankColRanking), hs, rankW + he * 0.4, bw), "rk-sub", "middle", 700, ' letter-spacing=".08em"'));
|
|
243
|
+
}
|
|
244
|
+
const top = header.top + hs * 1.6;
|
|
245
|
+
const rowH = Math.min(h, (avail - hs * 1.6) / (Math.max(1, rpc) * (1 + gap)));
|
|
246
|
+
slice.forEach((e, i) => {
|
|
247
|
+
const col = Math.floor(i / rpc), idx = i % rpc;
|
|
248
|
+
const r = { x: bx + col * (colW + colGap), y: top + idx * rowH * (1 + gap), w: colW, h: rowH };
|
|
249
|
+
const g = [];
|
|
250
|
+
const name = nameText(e.name, nameStyle, v);
|
|
251
|
+
g.push(`<rect class="rk-row" x="${num(r.x)}" y="${num(r.y)}" width="${num(r.w)}" height="${num(r.h)}" rx="${num(Math.min(r.h * 0.18, 16))}"/>`);
|
|
252
|
+
const cy = r.y + r.h / 2;
|
|
253
|
+
let x = r.x + rowPad;
|
|
254
|
+
const right = r.x + r.w - rowPad;
|
|
255
|
+
if (numbers) {
|
|
256
|
+
g.push(label(x + numW / 2, cy + he * 0.36 * 0.36, he * 0.36, v.number(from + i + 1), "rk-sub", "middle", 700));
|
|
257
|
+
x += numW;
|
|
258
|
+
}
|
|
259
|
+
if (marksAny) {
|
|
260
|
+
g.push(drawMark(teamFor(e, `e${from + i}`, name, abbreviate(e.club ?? name).slice(0, 3)), x + d / 2, cy, d, f.ctx));
|
|
261
|
+
x += d + rowPad;
|
|
262
|
+
}
|
|
263
|
+
// The ranking, big, at the right; the code and the points before it. They are written after the name, so that the markup reads in the order the row does.
|
|
264
|
+
const rt = rankTexts[i];
|
|
265
|
+
const tail = { rank: label(right - (rankW - he * 0.2) / 2, cy + rs * 0.36, fitSize(rt, rs, rankW - he * 0.2, 0.6), rt, e.rank === undefined ? "rk-sub" : "rk-ink", "middle", 800) };
|
|
266
|
+
let nr = right - rankW;
|
|
267
|
+
if (hasPoints) {
|
|
268
|
+
if (ptsTexts[i])
|
|
269
|
+
tail.points = label(nr, cy + ptsS * 0.36, ptsS, ptsTexts[i], "rk-sub", "end", 700);
|
|
270
|
+
nr -= ptsW;
|
|
271
|
+
}
|
|
272
|
+
if (hasCode) {
|
|
273
|
+
if (e.code)
|
|
274
|
+
tail.code = label(nr, cy + cs * 0.36, cs, e.code, "rk-sub", "end", 700);
|
|
275
|
+
nr -= codeW;
|
|
276
|
+
}
|
|
277
|
+
const nameAvail = Math.max(he, nr - x - rowPad * 0.5);
|
|
278
|
+
const second = e.club ?? e.country ?? "";
|
|
279
|
+
const twoLine = second !== "" && he >= 40;
|
|
280
|
+
const ns = fitSize(name, he * (twoLine ? 0.34 : 0.38), nameAvail, 0.55);
|
|
281
|
+
if (twoLine) {
|
|
282
|
+
const ss = he * 0.22;
|
|
283
|
+
const block = ns * 1.05 + ss * 1.3;
|
|
284
|
+
const t = cy - block / 2;
|
|
285
|
+
g.push(label(x, t + ns * 0.86, ns, fit(name, ns, nameAvail + 1, bw), "rk-ink", "start", 800));
|
|
286
|
+
g.push(label(x, t + ns * 1.05 + ss * 1.05, fitSize(second, ss, nameAvail, 0.7), fit(second, fitSize(second, ss, nameAvail, 0.7), nameAvail + 1, bw), "rk-sub", "start", 600));
|
|
287
|
+
}
|
|
288
|
+
else
|
|
289
|
+
g.push(label(x, cy + ns * 0.36, ns, fit(name, ns, nameAvail + 1, bw), "rk-ink", "start", 800));
|
|
290
|
+
g.push(...[tail.code, tail.points, tail.rank].filter((t) => !!t));
|
|
291
|
+
body.push(`<g class="rk-entry">${g.join("")}</g>`);
|
|
292
|
+
});
|
|
293
|
+
if (options.rows !== undefined || slice.length < all.length) {
|
|
294
|
+
const t = fill(w.rankRange, { a: v.number(from + 1), b: v.number(from + slice.length), n: v.number(all.length) });
|
|
295
|
+
if (slice.length < all.length)
|
|
296
|
+
body.push(label(bx + boxW, header.top - pad * 0.15, hs, fit(t, hs, boxW * 0.35, bw), "rk-sub", "end", 700));
|
|
297
|
+
}
|
|
298
|
+
return boardWrap(f, options.title ?? [title, source.category].filter(Boolean).join(": "), describeEntryList(source, options), body, "entry-list");
|
|
299
|
+
}
|
|
300
|
+
// ------------------------------------------------------------------------------------------------ shared header
|
|
301
|
+
/** The heading every ranking board has: the title, an optional category pill, the date at the right and the source at the foot. Returns the shapes and the space left. */
|
|
302
|
+
function heading(f, title, category, date, pad, maxBoxW, source) {
|
|
303
|
+
const { W, H } = f;
|
|
304
|
+
const unit = Math.min(W, H);
|
|
305
|
+
const boxW = Math.min(W - pad * 2, maxBoxW);
|
|
306
|
+
const bx = (W - boxW) / 2;
|
|
307
|
+
const hs = clampN(unit * 0.05, 18, 70);
|
|
308
|
+
const cs = hs * 0.58;
|
|
309
|
+
const svg = [];
|
|
310
|
+
let y = pad;
|
|
311
|
+
const dateW = date ? Math.min(boxW * 0.4, bw(date, cs) + 4) : 0;
|
|
312
|
+
const titleAvail = boxW - (dateW ? dateW + pad : 0);
|
|
313
|
+
const ts = fitSize(title, hs, titleAvail, 0.5);
|
|
314
|
+
svg.push(label(bx, y + hs * 0.85, ts, fit(title, ts, titleAvail + 1, bw), "rk-ink", "start", 800));
|
|
315
|
+
if (date)
|
|
316
|
+
svg.push(label(bx + boxW, y + hs * 0.85, cs, fit(date, cs, dateW, bw), "rk-sub", "end", 700));
|
|
317
|
+
y += hs * 1.05;
|
|
318
|
+
if (category)
|
|
319
|
+
svg.push(pill(bx, y + cs * 0.7, cs * 1.35, category, boxW * 0.62, "rk-accent", "rk-accentink").svg);
|
|
320
|
+
y += category ? cs * 1.35 + pad * 0.7 : pad * 0.35;
|
|
321
|
+
const footerH = source ? Math.max(14, unit * 0.026) + pad * 0.5 : 0;
|
|
322
|
+
if (source) {
|
|
323
|
+
const fs = Math.max(12, unit * 0.022);
|
|
324
|
+
svg.push(label(bx, H - pad - fs * 0.2, fs, fit(source, fs, boxW, bw), "rk-sub", "start", 600));
|
|
325
|
+
}
|
|
326
|
+
return { svg, top: y, bottom: H - pad - footerH, bx, boxW };
|
|
327
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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 { type BoardOptions } from "./rank-kit.ts";
|
|
8
|
+
import { type Voice } from "./locales.ts";
|
|
9
|
+
import type { Team } from "./types.ts";
|
|
10
|
+
/** A name in two parts: the family name is written in capitals and first by default (`SURNAME Given`, as judo and the IJF write it). */
|
|
11
|
+
export interface RankingName {
|
|
12
|
+
given?: string;
|
|
13
|
+
surname: string;
|
|
14
|
+
}
|
|
15
|
+
/** One line of a ranking. Only `name` is needed. */
|
|
16
|
+
export interface RankingEntry {
|
|
17
|
+
/** The name as it is to be written, or its two parts (set by `nameStyle`). */
|
|
18
|
+
name: string | RankingName;
|
|
19
|
+
/** The rank. Left out, the position in the list, with equal points sharing a rank (`ties`). A champion has none. */
|
|
20
|
+
rank?: number;
|
|
21
|
+
/** A number (shown to `precision` decimals) or text (shown as it is). */
|
|
22
|
+
points?: number | string;
|
|
23
|
+
club?: string;
|
|
24
|
+
/** The country's or region's name, written under the name in a podium row. */
|
|
25
|
+
country?: string;
|
|
26
|
+
/** The country's three letters (`CAN`), written in the compact rows. */
|
|
27
|
+
code?: string;
|
|
28
|
+
/** A code for the `flags` renderer: a country (`JP`) or a Canadian province or territory (`CA-BC`). */
|
|
29
|
+
flag?: string;
|
|
30
|
+
/** A mark of your own (an image address or inline SVG) in place of a flag: a club's emblem. */
|
|
31
|
+
mark?: string;
|
|
32
|
+
/** This athlete's category ("U18 · -60 kg"), written in the second line when `categories` is on: for a list that mixes categories. */
|
|
33
|
+
category?: string;
|
|
34
|
+
/** The movement since the last list: whole places up (positive) or down (negative), `0` for none, `"new"` for newly ranked ("NR"). */
|
|
35
|
+
change?: number | "new";
|
|
36
|
+
/** The champion of the division: a row marked "C" above the ranks (`"interim"` for an interim champion). */
|
|
37
|
+
champion?: boolean | "interim";
|
|
38
|
+
}
|
|
39
|
+
export interface RankingSource {
|
|
40
|
+
/** The heading. Default: the language's word for ranking. */
|
|
41
|
+
title?: string;
|
|
42
|
+
/** The category as a line under the heading ("U18 · Men -60 kg"; `rankingCategoryLabel` writes one). */
|
|
43
|
+
category?: string;
|
|
44
|
+
/** The date or period of the list, at the right of the heading ("October 2026"). */
|
|
45
|
+
date?: string;
|
|
46
|
+
/** Where the list comes from, small at the foot ("Source: BC Judo"). */
|
|
47
|
+
source?: string;
|
|
48
|
+
entries: readonly RankingEntry[];
|
|
49
|
+
}
|
|
50
|
+
export interface RankingOptions extends BoardOptions {
|
|
51
|
+
/** `podium` (the default): the top three as big rows, tinted gold, silver and bronze, above the compact rest. `featured`: one big row above two columns. `list`: every row compact. */
|
|
52
|
+
style?: "podium" | "list" | "featured";
|
|
53
|
+
/** How many rows, from the top, are big (0 to 3). Default by `style`. Only on the first page. */
|
|
54
|
+
big?: number;
|
|
55
|
+
/** One column or two for the compact rows (the first column runs down before the second starts). Default 1, and 2 for `featured`. */
|
|
56
|
+
columns?: 1 | 2;
|
|
57
|
+
/** The most entries drawn, from the first of the page. Default: as many as fit at a readable size. */
|
|
58
|
+
rows?: number;
|
|
59
|
+
/** Which page of `rows` entries (0 is the first). The header says which entries are shown. */
|
|
60
|
+
page?: number;
|
|
61
|
+
/** On a wide board (at least 3:2) the big rows go in a panel on the left and the compact ones beside them, in place of one above the other. Default true; `false` stacks them. */
|
|
62
|
+
split?: boolean;
|
|
63
|
+
/** Tint the rows of ranks 1 to 3 gold, silver and bronze. Default: on for `podium` and `featured` (off when the list has a champion row), off for `list`. */
|
|
64
|
+
medals?: boolean;
|
|
65
|
+
/** How movement is shown: `arrow` (an arrow and the number of places, the default), `row` (the row tinted green or red), `both`, or `none`. */
|
|
66
|
+
movement?: "arrow" | "row" | "both" | "none";
|
|
67
|
+
/** `surname-first` (the default: `SURNAME Given`), `given-first` (`Given SURNAME`) or `as-is`, for a `name` given in two parts. */
|
|
68
|
+
nameStyle?: "surname-first" | "given-first" | "as-is";
|
|
69
|
+
/** `plain` (`10`, the default) or `hash` (`#10`). */
|
|
70
|
+
rankStyle?: "plain" | "hash";
|
|
71
|
+
/** Decimals for numeric points (`2` writes `91.60` and `50.00`). Default: as many as the number has, up to three. */
|
|
72
|
+
precision?: number;
|
|
73
|
+
/** Equal points share a rank where an entry names none (`1, 2, 2, 4`). Default true. */
|
|
74
|
+
ties?: boolean;
|
|
75
|
+
/** `given` (the default) keeps the order of the entries; `points` puts the most points first. */
|
|
76
|
+
sort?: "given" | "points";
|
|
77
|
+
/** Write each entry's own `category` in the second line. */
|
|
78
|
+
categories?: boolean;
|
|
79
|
+
/** Draw a club's (or the athlete's) initials on a badge for an entry with no flag or mark, even where no entry has one. Default: only where some entry has a flag or mark. */
|
|
80
|
+
badge?: boolean;
|
|
81
|
+
}
|
|
82
|
+
/** 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. */
|
|
83
|
+
export declare function rankingRanks(entries: readonly RankingEntry[], options?: {
|
|
84
|
+
ties?: boolean;
|
|
85
|
+
}): (number | null)[];
|
|
86
|
+
/** 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. */
|
|
87
|
+
export declare function rankingCategoryLabel(category: {
|
|
88
|
+
age?: string;
|
|
89
|
+
sex?: "men" | "women" | "open";
|
|
90
|
+
weight?: string;
|
|
91
|
+
}, locale?: string): string;
|
|
92
|
+
export declare const voiceFor: (locale?: string) => Voice;
|
|
93
|
+
export declare const nameText: (n: RankingEntry["name"], style: NonNullable<RankingOptions["nameStyle"]>, v: Voice) => string;
|
|
94
|
+
export declare const pointsText: (p: RankingEntry["points"], precision: number | undefined, v: Voice) => string;
|
|
95
|
+
export interface R {
|
|
96
|
+
x: number;
|
|
97
|
+
y: number;
|
|
98
|
+
w: number;
|
|
99
|
+
h: number;
|
|
100
|
+
}
|
|
101
|
+
/** The ranking as a sentence, the drawing's description. */
|
|
102
|
+
export declare function describeRanking(source: RankingSource, options?: Pick<RankingOptions, "locale" | "words" | "nameStyle" | "precision" | "ties" | "sort" | "rankStyle" | "rows" | "page">): string;
|
|
103
|
+
export declare const teamFor: (e: RankingEntry, id: string, name: string, badge: string) => Team;
|
|
104
|
+
/**
|
|
105
|
+
* 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.
|
|
106
|
+
*/
|
|
107
|
+
export declare function rankingList(source: RankingSource, options?: RankingOptions): string;
|