@johnmorrisdotca/taisen-brackets 0.6.1 → 0.8.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.
@@ -0,0 +1,460 @@
1
+ import { judoClock, judoOutcome, judoSeconds } from "./judo-model.js";
2
+ import { judoMethodWord, judoSideLong } from "./judo-text.js";
3
+ import { fill, voiceOf } from "./locales.js";
4
+ import { drawMark, markDefs } from "./marks.js";
5
+ import { JUDO_COLORS } from "./theme.js";
6
+ import { capitals, emWidth, esc, fit, fitLines, hash, num } from "./text.js";
7
+ /** The sizes: a TV's 1920 by 1080, a tablet on its side, 1280 by 800, and a tablet held upright, 800 by 1280. */
8
+ export const JUDO_BOARD_SIZES = Object.freeze({
9
+ tv: { width: 1920, height: 1080 },
10
+ tablet: { width: 1280, height: 800 },
11
+ "tablet-tall": { width: 800, height: 1280 },
12
+ });
13
+ const clampN = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
14
+ const bw = (text, size) => emWidth(text) * size;
15
+ const LIGHT = { bg: "#ecebe4", ink: "#1b1e1c", muted: "#585d57", band: "#dcdad0", box: "#141716", boxInk: "#f4f1e6", gold: "#e0b43b" };
16
+ const DARK = { bg: "#0e1011", ink: "#eceae0", muted: "#9aa097", band: "#1b1f21", box: "#050606", boxInk: "#f4f1e6", gold: "#e0b43b" };
17
+ const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Hiragino Sans", "Yu Gothic UI", "Noto Sans CJK JP", Meiryo, sans-serif';
18
+ /** The panels' own colours: the white and the blue judogi, the ink and the tiles on each. White on #1f4fa8 reads 8 to 1, and the dark ink on the white panel more. */
19
+ const WHITE_PANEL = { panel: "#f7f6f1", ink: "#15181a", sub: "#464b50", tile: "#e0dfd6", edge: "#bdbbae" };
20
+ const BLUE_PANEL = { panel: JUDO_COLORS.blue, ink: "#ffffff", sub: "#dbe5fa", tile: "#173b86", edge: "#0f2a63" };
21
+ function rulesFor(p, scope, side) {
22
+ const s = (sel, body) => `${side}${scope} ${sel}{${body}}`;
23
+ const v = (name, value) => `var(--jb-${name}, ${value})`;
24
+ return [
25
+ s(".jb-bg", `fill:${v("bg", p.bg)}`),
26
+ s(".jb-band", `fill:${v("band", p.band)}`),
27
+ s(".jb-ink", `fill:${v("ink", p.ink)}`),
28
+ s(".jb-muted", `fill:${v("muted", p.muted)}`),
29
+ s(".jb-box", `fill:${v("box", p.box)}`),
30
+ s(".jb-boxink", `fill:${v("box-ink", p.boxInk)}`),
31
+ s(".jb-gold", `fill:${v("gold", p.gold)}`),
32
+ s(".jb-goldbox", `fill:${v("gold", p.gold)}`),
33
+ s(".jb-win", `fill:none;stroke:${v("gold", p.gold)};stroke-width:6`),
34
+ s(".jb-clockedge", `fill:none;stroke:${v("gold", p.gold)};stroke-width:3`),
35
+ ].join("\n");
36
+ }
37
+ /** The panels' rules and the small fixed-colour parts, the same in every theme. */
38
+ function fixedRules(scope) {
39
+ return [
40
+ `.${scope} .jb-w .jb-panel{fill:var(--jb-white-panel, ${WHITE_PANEL.panel});stroke:${WHITE_PANEL.edge};stroke-width:2}`,
41
+ `.${scope} .jb-w .jb-pink{fill:${WHITE_PANEL.ink}}`,
42
+ `.${scope} .jb-w .jb-psub{fill:${WHITE_PANEL.sub}}`,
43
+ `.${scope} .jb-w .jb-tile{fill:${WHITE_PANEL.tile}}`,
44
+ `.${scope} .jb-w .jb-tile.jb-on{stroke:#8a6a00;stroke-width:4}`,
45
+ `.${scope} .jb-w .jb-slot{fill:none;stroke:#8d9196;stroke-width:2}`,
46
+ `.${scope} .jb-w .jb-fillside{fill:${WHITE_PANEL.panel}}`,
47
+ `.${scope} .jb-b .jb-panel{fill:var(--jb-blue-panel, ${BLUE_PANEL.panel});stroke:${BLUE_PANEL.edge};stroke-width:2}`,
48
+ `.${scope} .jb-b .jb-pink{fill:${BLUE_PANEL.ink}}`,
49
+ `.${scope} .jb-b .jb-psub{fill:${BLUE_PANEL.sub}}`,
50
+ `.${scope} .jb-b .jb-tile{fill:${BLUE_PANEL.tile}}`,
51
+ `.${scope} .jb-b .jb-tile.jb-on{stroke:#ffd84d;stroke-width:4}`,
52
+ `.${scope} .jb-b .jb-slot{fill:none;stroke:#9db5ea;stroke-width:2}`,
53
+ `.${scope} .jb-b .jb-fillside{fill:#4a86f5}`,
54
+ `.${scope} .jb-sy{fill:var(--jb-shido, ${JUDO_COLORS.shido})}`,
55
+ `.${scope} .jb-sr{fill:var(--jb-hansoku, ${JUDO_COLORS.hansoku})}`,
56
+ `.${scope} .jb-card{fill:#ffffff;stroke:#b4b4ac;stroke-width:2}`,
57
+ `.${scope} .jb-w .jb-lowflap{fill:#ffffff}`,
58
+ `.${scope} .jb-b .jb-lowflap{fill:${BLUE_PANEL.panel}}`,
59
+ `.${scope} .jb-hinge{stroke:#9a9a92;stroke-width:2}`,
60
+ `.${scope} .jb-w .jb-num{fill:#15181a}`,
61
+ `.${scope} .jb-b .jb-num{fill:${BLUE_PANEL.panel}}`,
62
+ `.${scope} .jb-zero{opacity:.38}`,
63
+ `.${scope} .jb-w .jb-lab{fill:#15181a}`,
64
+ `.${scope} .jb-b .jb-lab{fill:#ffffff}`,
65
+ `.${scope} .jb-ringbind{fill:none;stroke:#7d8186;stroke-width:3;stroke-linecap:round}`,
66
+ `.${scope} .jb-stand{fill:#cbcbc3;stroke:#a5a59d;stroke-width:2}`,
67
+ `.${scope} .jb-base{fill:#e4e4de;stroke:#a5a59d;stroke-width:2}`,
68
+ `.${scope} .jb-track{fill:#2c3234}`,
69
+ `.${scope} .jb-tick{stroke:#8f979a;stroke-width:2}`,
70
+ `.${scope} .tb-ring{fill:none;stroke:rgba(0,0,0,.35);stroke-width:2}`,
71
+ `.${scope} .tb-plain{fill:#d9d3c2}`,
72
+ `.${scope} .tb-initials{fill:#1f2320}`,
73
+ `.${scope} text{font-family:var(--jb-font, ${FONT});font-variant-numeric:tabular-nums}`,
74
+ ].join("\n");
75
+ }
76
+ /** The `<style>` text: the frame's colours by theme, the panels' fixed ones. Every colour is a custom property with a fallback (`--jb-bg`, `--jb-ink`, `--jb-blue-panel` and so on). */
77
+ function styleFor(scope, theme) {
78
+ const base = (p, before) => rulesFor(p, `.${scope}`, before);
79
+ const fixed = fixedRules(scope);
80
+ if (theme === "light")
81
+ return `${base(LIGHT, "")}\n${fixed}`;
82
+ if (theme === "dark")
83
+ return `${base(DARK, "")}\n${fixed}`;
84
+ return `${base(LIGHT, "")}\n@media (prefers-color-scheme: dark){\n${base(DARK, ':root:not([data-theme="light"]) ')}\n}\n${base(DARK, ':root[data-theme="dark"] ')}\n${fixed}`;
85
+ }
86
+ const mmss = (seconds) => judoClock(seconds);
87
+ /** The outcome of the state: who has won and how, from the scores, or `null` while nothing has ended it (a lead wins by decision only once `finished`). */
88
+ function resultOf(c) {
89
+ const sides = c.sides ?? [{}, {}];
90
+ return judoOutcome({ sides, ...(c.method ? { method: c.method } : {}), ...(c.hansokuMake !== undefined ? { hansokuMake: c.hansokuMake } : {}), ...(c.goldenScore ? { goldenScore: true } : {}) }, c.winner ?? null);
91
+ }
92
+ /** The words of the state in a sentence, for the drawing's description. */
93
+ export function describeJudoContest(contest, options = {}) {
94
+ const v = voiceOf(options);
95
+ const w = v.words;
96
+ const out = resultOf(contest);
97
+ const parts = [];
98
+ if (contest.weight)
99
+ parts.push(contest.weight);
100
+ if (contest.round)
101
+ parts.push(contest.round);
102
+ if (contest.mat !== undefined)
103
+ parts.push(fill(w.judoMat, { n: String(contest.mat) }));
104
+ if (contest.contest !== undefined)
105
+ parts.push(fill(w.judoContest, { n: String(contest.contest) }));
106
+ [0, 1].forEach((k) => {
107
+ const a = k === 0 ? contest.white : contest.blue;
108
+ parts.push(`${k === 0 ? w.judoWhite : w.judoBlue}: ${a.name}${a.code ? ` (${a.code})` : ""}, ${judoSideLong(out.sides[k], v)}${out.sides[k].shido ? `, ${fill(w.judoShidoOf, { name: w.judoShido, n: v.number(out.sides[k].shido) })}` : ""}`);
109
+ });
110
+ if (contest.clock !== undefined)
111
+ parts.push(mmss(judoSeconds(contest.clock) ?? 0));
112
+ if (contest.goldenScore)
113
+ parts.push(w.judoGoldenLong);
114
+ if (contest.osaekomi)
115
+ parts.push(`${w.judoOsaekomi} ${v.number(contest.osaekomi.seconds)} (${contest.osaekomi.side === 0 ? w.judoWhite : w.judoBlue})`);
116
+ const winner = winnerOf(contest, out);
117
+ if (winner !== null)
118
+ parts.push(fill(w.winnerIs, { name: (winner === 0 ? contest.white : contest.blue).name }) + (out.method ? ` (${judoMethodWord(out.method, v)})` : ""));
119
+ return parts.join(w.sep) + w.end;
120
+ }
121
+ /** The winner to show: the outcome's, but a lead on the scores only counts once the contest is finished. */
122
+ function winnerOf(c, out) {
123
+ if (out.winner === null)
124
+ return null;
125
+ if (out.method === "score" && c.finished !== true)
126
+ return null;
127
+ return out.winner;
128
+ }
129
+ /**
130
+ * The scoreboard of a contest as an SVG string. Throws a `RangeError` for a state that cannot be (two ippon, a third waza-ari, a clock that is not seconds or `m:ss`, an osaekomi
131
+ * past 20 seconds is clamped to 20).
132
+ */
133
+ export function judoScoreboard(contest, options = {}) {
134
+ const v = voiceOf(options);
135
+ const w = v.words;
136
+ const named = typeof options.size === "string" ? JUDO_BOARD_SIZES[options.size] : options.size ?? JUDO_BOARD_SIZES.tv;
137
+ if (!named || !(named.width >= 200) || !(named.height >= 200))
138
+ throw new RangeError(`taisen-brackets: a scoreboard size is "tv", "tablet", "tablet-tall" or { width, height } of 200 or more, not ${JSON.stringify(options.size)}`);
139
+ const W = named.width, H = named.height;
140
+ const theme = options.theme ?? "auto";
141
+ if (theme !== "auto" && theme !== "light" && theme !== "dark")
142
+ throw new RangeError(`taisen-brackets: a scoreboard theme is "auto", "light" or "dark", not ${JSON.stringify(theme)}`);
143
+ if (options.style !== undefined && options.style !== "board" && options.style !== "flip")
144
+ throw new RangeError(`taisen-brackets: a scoreboard style is "board" or "flip", not ${JSON.stringify(options.style)}`);
145
+ const out = resultOf(contest);
146
+ const winner = winnerOf(contest, out);
147
+ const clockSeconds = judoSeconds(contest.clock);
148
+ const osa = contest.osaekomi;
149
+ if (osa && (!(osa.side === 0 || osa.side === 1) || !Number.isFinite(osa.seconds) || osa.seconds < 0))
150
+ throw new RangeError("taisen-brackets: osaekomi is { side: 0 | 1, seconds } with seconds from 0");
151
+ const prefix = options.id ?? `jb-${hash(JSON.stringify([contest, options.size, options.style, theme, options.swap, options.coaches, v.locale.code, options.flags ? 1 : 0, options.flagHref ? 1 : 0, options.title]))}`;
152
+ const ctx = { radii: new Set(), symbols: new Map(), prefix, shape: "round", theme: "inherit", flagCache: new Map(), ...(options.flags ? { flags: options.flags } : {}), ...(options.flagHref ? { flagHref: options.flagHref } : {}) };
153
+ const landscape = W >= H;
154
+ const unit = Math.min(W, H);
155
+ const pad = Math.round(unit * (landscape ? 0.02 : 0.025));
156
+ const headH = Math.round(H * (landscape ? 0.11 : 0.085));
157
+ const footH = Math.round(H * (landscape ? 0.1 : 0.065));
158
+ const body = [];
159
+ const t = (x, y, size, text, cls, anchor = "start", weight = 800, extra = "") => `<text class="${cls}" x="${num(x)}" y="${num(y)}" font-size="${num(size)}" font-weight="${weight}" text-anchor="${anchor}"${extra}>${esc(text)}</text>`;
160
+ // ---- The header: weight class, round, mat and contest number on a band.
161
+ body.push(`<rect class="jb-band" x="0" y="0" width="${num(W)}" height="${num(headH)}"/>`);
162
+ const headFont = clampN(headH * 0.46, 14, 64);
163
+ const headMid = headH / 2 + headFont * 0.36;
164
+ const where = [contest.mat !== undefined ? fill(w.judoMat, { n: String(contest.mat) }) : "", contest.contest !== undefined ? fill(w.judoContest, { n: String(contest.contest) }) : ""].filter(Boolean).join(" · ");
165
+ const leftText = contest.weight ?? "";
166
+ const midText = contest.round ?? "";
167
+ const rightText = where;
168
+ const inner = W - pad * 2;
169
+ if (landscape) {
170
+ if (leftText)
171
+ body.push(t(pad, headMid, fitSize(leftText, headFont, inner * 0.34, 0.5), fit(leftText, fitSize(leftText, headFont, inner * 0.34, 0.5), inner * 0.34, bw), "jb-ink", "start", 800));
172
+ if (midText)
173
+ body.push(t(W / 2, headMid, fitSize(midText, headFont, inner * 0.2, 0.5), fit(midText, fitSize(midText, headFont, inner * 0.2, 0.5), inner * 0.2, bw), "jb-ink", "middle", 800));
174
+ if (rightText)
175
+ body.push(t(W - pad, headMid, fitSize(rightText, headFont, inner * 0.4, 0.5), fit(rightText, fitSize(rightText, headFont, inner * 0.4, 0.5), inner * 0.4, bw), "jb-muted", "end", 800));
176
+ }
177
+ else {
178
+ const first = [leftText, midText].filter(Boolean).join(" · ");
179
+ const f1 = headFont * 0.82, f2 = headFont * 0.62;
180
+ if (first)
181
+ body.push(t(pad, headH * 0.46, fitSize(first, f1, W - pad * 2), fit(first, f1, W - pad * 2, bw), "jb-ink", "start", 800));
182
+ if (rightText)
183
+ body.push(t(pad, headH * 0.46 + f2 * 1.25, fitSize(rightText, f2, W - pad * 2), fit(rightText, f2, W - pad * 2, bw), "jb-muted", "start", 800));
184
+ }
185
+ // ---- The areas.
186
+ const top = headH + pad;
187
+ const bottom = H - footH - pad;
188
+ const availH = bottom - top;
189
+ const [firstSide, secondSide] = (options.swap ? [1, 0] : [0, 1]);
190
+ let rectA, rectB, rectC;
191
+ if (landscape) {
192
+ const cw = Math.round((W - pad * 4) * (options.style === "flip" ? 0.2 : 0.24));
193
+ const pw = Math.round((W - pad * 4 - cw) / 2);
194
+ rectA = { x: pad, y: top, w: pw, h: availH };
195
+ rectC = { x: pad * 2 + pw, y: top, w: cw, h: availH };
196
+ rectB = { x: pad * 3 + pw + cw, y: top, w: pw, h: availH };
197
+ }
198
+ else {
199
+ const ch = Math.round((availH - pad * 2) * 0.24);
200
+ const ph = Math.round((availH - pad * 2 - ch) / 2);
201
+ rectA = { x: pad, y: top, w: W - pad * 2, h: ph };
202
+ rectC = { x: pad, y: top + ph + pad, w: W - pad * 2, h: ch };
203
+ rectB = { x: pad, y: top + ph + pad * 2 + ch, w: W - pad * 2, h: ph };
204
+ }
205
+ /** The name block: the flag, the name, the code and the coach, in a box `inner` wide whose top `nameH` it fills. */
206
+ const nameBlock = (g, inner, nameH, a, k) => {
207
+ const flagD = Math.min(nameH * 0.84, inner.w * 0.24);
208
+ const fx = inner.x + flagD / 2, fy = inner.y + nameH / 2;
209
+ const team = { id: k, name: a.name, abbr: (a.code ?? a.name).slice(0, 3).toUpperCase(), ...(a.flag ? { flag: a.flag } : {}), ...(a.mark ? { mark: a.mark } : {}) };
210
+ g.push(drawMark(team, fx, fy, flagD, ctx));
211
+ const tx = inner.x + flagD + inner.w * 0.035;
212
+ const tw = inner.x + inner.w - tx;
213
+ const showCoach = (options.coaches ?? true) && !!a.coach;
214
+ const lines = (a.code ? 1 : 0) + (showCoach ? 1 : 0);
215
+ const nameMaxH = nameH * (lines === 0 ? 1 : lines === 1 ? 0.62 : 0.5);
216
+ const fl = fitLines(a.name, tw, nameMaxH, Math.min(nameH * 0.5, 120));
217
+ const blockH = fl.height + (a.code ? nameH * 0.28 : 0) + (showCoach ? nameH * 0.17 : 0);
218
+ let y = inner.y + (nameH - blockH) / 2;
219
+ fl.lines.forEach((line, i) => {
220
+ g.push(t(tx, y + fl.size * (i + 0.86), fl.size, fit(line, fl.size, tw + 1, bw), "jb-pink", "start", 800));
221
+ });
222
+ y += fl.height;
223
+ if (a.code) {
224
+ const cs = nameH * 0.24;
225
+ g.push(t(tx, y + cs * 1.05, cs, a.code, "jb-psub", "start", 800, ' letter-spacing=".08em"'));
226
+ y += nameH * 0.28;
227
+ }
228
+ if (showCoach) {
229
+ const cs = nameH * 0.13;
230
+ g.push(t(tx, y + cs * 1.05, cs, fit(fill(w.judoCoach, { name: a.coach }), cs, tw, bw), "jb-psub", "start", 600));
231
+ }
232
+ };
233
+ const sideOf = (k) => (k === 0 ? contest.white : contest.blue);
234
+ const panel = (rect, k) => {
235
+ const a = sideOf(k);
236
+ const s = out.sides[k];
237
+ const cls = k === 0 ? "jb-w" : "jb-b";
238
+ const g = [];
239
+ const rx = Math.round(unit * 0.012);
240
+ g.push(`<rect class="jb-panel" x="${num(rect.x)}" y="${num(rect.y)}" width="${num(rect.w)}" height="${num(rect.h)}" rx="${rx}"/>`);
241
+ const ip = Math.max(pad, rect.w * 0.03);
242
+ const inner = { x: rect.x + ip, y: rect.y + ip, w: rect.w - ip * 2, h: rect.h - ip * 2 };
243
+ const wideP = inner.w / inner.h > 1.4;
244
+ const nameH = inner.h * (wideP ? 0.37 : 0.34), cntH = inner.h * (wideP ? 0.4 : 0.43), shidoH = inner.h - nameH - cntH;
245
+ nameBlock(g, inner, nameH, a, k);
246
+ // The counters: ippon, waza-ari, yuko, each a tile with its label and a big number.
247
+ const gap = Math.max(8, inner.w * 0.025);
248
+ const tileW = (inner.w - gap * 2) / 3;
249
+ const tileY = inner.y + nameH;
250
+ const tileH = cntH - gap;
251
+ const counters = [[w.judoIpponLong, s.ippon ? 1 : 0, s.ippon], [w.judoWazaAriLong, s.wazaAri, s.wazaAri > 0], [w.judoYukoLong, s.yuko, s.yuko > 0]];
252
+ counters.forEach(([label, n, on], i) => {
253
+ const x0 = inner.x + i * (tileW + gap);
254
+ g.push(`<rect class="jb-tile${on ? " jb-on" : ""}" x="${num(x0)}" y="${num(tileY)}" width="${num(tileW)}" height="${num(tileH)}" rx="${num(rx)}"/>`);
255
+ const ls = clampN(tileH * 0.13, 11, 38);
256
+ const labelText = capitals(label);
257
+ const lsz = fitSize(labelText, ls, tileW - 16, 0.4);
258
+ g.push(t(x0 + tileW / 2, tileY + ls * 1.45, lsz, fit(labelText, lsz, tileW - 12, bw), "jb-psub", "middle", 800, ' letter-spacing=".03em"'));
259
+ const ds = Math.min(tileH * 0.62, tileW * 0.78);
260
+ g.push(t(x0 + tileW / 2, tileY + tileH - (tileH - ls * 1.7 - ds * 0.72) / 2, ds, v.number(n), "jb-pink", "middle", 800));
261
+ });
262
+ // The shido: three slots, a yellow card for each, and the third (hansoku-make) red.
263
+ const sy = inner.y + nameH + cntH;
264
+ const cards = [null, null, null];
265
+ const n = s.shido;
266
+ for (let i = 0; i < Math.min(n, 3); i++)
267
+ cards[i] = i === 2 ? "r" : "y";
268
+ // A direct hansoku-make is a red card on the side given it.
269
+ if (out.method === "hansoku-make" && out.winner !== null && out.winner !== k && n < 3)
270
+ cards[2] = "r";
271
+ const labelS = clampN(shidoH * 0.34, 11, 34);
272
+ const slotH = Math.min(shidoH * 0.62, 60), slotW = slotH * 0.72;
273
+ const sx0 = inner.x;
274
+ g.push(t(sx0, sy + shidoH / 2 + labelS * 0.35, labelS, capitals(w.judoShido), "jb-psub", "start", 800, ' letter-spacing=".08em"'));
275
+ const startX = inner.x + inner.w - (slotW * 3 + gap * 2);
276
+ cards.forEach((c, i) => {
277
+ const x0 = startX + i * (slotW + gap);
278
+ const y0 = sy + (shidoH - slotH) / 2;
279
+ g.push(c ? `<rect class="${c === "r" ? "jb-sr" : "jb-sy"}" x="${num(x0)}" y="${num(y0)}" width="${num(slotW)}" height="${num(slotH)}" rx="${num(slotW * 0.12)}"/>` : `<rect class="jb-slot" x="${num(x0)}" y="${num(y0)}" width="${num(slotW)}" height="${num(slotH)}" rx="${num(slotW * 0.12)}"/>`);
280
+ });
281
+ if (winner === k)
282
+ g.push(`<rect class="jb-win" x="${num(rect.x + 3)}" y="${num(rect.y + 3)}" width="${num(rect.w - 6)}" height="${num(rect.h - 6)}" rx="${rx}"/>`);
283
+ body.push(`<g class="${cls}${winner === k ? " jb-winner" : ""}">${g.join("")}</g>`);
284
+ };
285
+ /** A hansoku-make on the side: the third shido, or a direct one (the other side won by it); both sides on a double. */
286
+ const hansokuOf = (k) => out.sides[k].shido >= 3 || (out.method === "hansoku-make" && out.winner !== null && out.winner !== k);
287
+ /** The flip-card style: a row of ring-bound cards on a stand for each side, one card to a counter, the label printed on the card. */
288
+ const flipGroup = (rect, k) => {
289
+ const a = sideOf(k);
290
+ const s = out.sides[k];
291
+ const cls = k === 0 ? "jb-w" : "jb-b";
292
+ const g = [];
293
+ const rx = Math.round(unit * 0.012);
294
+ const plateH = rect.h * (landscape ? 0.26 : 0.3);
295
+ g.push(`<rect class="jb-panel" x="${num(rect.x)}" y="${num(rect.y)}" width="${num(rect.w)}" height="${num(plateH)}" rx="${rx}"/>`);
296
+ const ip = Math.max(pad * 0.8, rect.w * 0.025);
297
+ nameBlock(g, { x: rect.x + ip, y: rect.y + ip * 0.6, w: rect.w - ip * 2, h: plateH - ip * 1.2 }, plateH - ip * 1.2, a, k);
298
+ const cards = [
299
+ [w.judoIpponLong, s.ippon ? 1 : 0, "ippon"], [w.judoWazaAriLong, s.wazaAri, "waza"], [w.judoYukoLong, s.yuko, "yuko"], [w.judoShido, s.shido, "shido"], [w.judoMethodHansoku, hansokuOf(k) ? 1 : 0, "hansoku"],
300
+ ];
301
+ const gap = rect.w * 0.018;
302
+ const side = rect.w * 0.035;
303
+ const cw = (rect.w - side * 2 - gap * (cards.length - 1)) / cards.length;
304
+ const ringH = Math.min(cw * 0.22, 30);
305
+ const areaY = rect.y + plateH + pad, areaH = rect.h - plateH - pad;
306
+ const baseH = Math.max(14, Math.min(areaH * 0.07, 40));
307
+ const ch = Math.min(areaH * 0.78 - baseH - ringH, cw * 2.05);
308
+ const standH = ringH + ch + baseH + ch * 0.1;
309
+ const standY = areaY + (areaH - standH) / 2;
310
+ // The stand: a back plate the cards hang on, and the base in front of it.
311
+ g.push(`<rect class="jb-stand" x="${num(rect.x)}" y="${num(standY)}" width="${num(rect.w)}" height="${num(standH - baseH * 0.5)}" rx="${rx}"/>`);
312
+ g.push(`<rect class="jb-base" x="${num(rect.x - rect.w * 0.012)}" y="${num(standY + standH - baseH)}" width="${num(rect.w * 1.024)}" height="${num(baseH)}" rx="${num(baseH * 0.3)}"/>`);
313
+ const cy = standY + ringH + ch * 0.06;
314
+ cards.forEach(([label, n, id], i) => {
315
+ const x0 = rect.x + side + i * (cw + gap);
316
+ const topH = ch * 0.58, lowH = ch - topH;
317
+ const on = n > 0;
318
+ g.push(`<rect class="jb-card${on ? " jb-oncard" : ""}" x="${num(x0)}" y="${num(cy)}" width="${num(cw)}" height="${num(ch)}" rx="${num(cw * 0.06)}"/>`);
319
+ g.push(`<path class="jb-lowflap" d="M${num(x0)} ${num(cy + topH)}H${num(x0 + cw)}V${num(cy + ch - cw * 0.06)}Q${num(x0 + cw)} ${num(cy + ch)} ${num(x0 + cw - cw * 0.06)} ${num(cy + ch)}H${num(x0 + cw * 0.06)}Q${num(x0)} ${num(cy + ch)} ${num(x0)} ${num(cy + ch - cw * 0.06)}Z"/>`);
320
+ g.push(`<line class="jb-hinge" x1="${num(x0)}" x2="${num(x0 + cw)}" y1="${num(cy + topH)}" y2="${num(cy + topH)}"/>`);
321
+ // Two rings through the top edge.
322
+ for (const fx of [0.28, 0.72])
323
+ g.push(`<path class="jb-ringbind" d="M${num(x0 + cw * fx - ringH * 0.32)} ${num(cy + ringH * 0.2)}V${num(cy - ringH * 0.55)}a${num(ringH * 0.32)} ${num(ringH * 0.32)} 0 0 1 ${num(ringH * 0.64)} 0V${num(cy + ringH * 0.2)}"/>`);
324
+ const ds = Math.min(topH * 0.82, cw * 0.95);
325
+ g.push(`<text class="jb-num${on ? "" : " jb-zero"}" x="${num(x0 + cw / 2)}" y="${num(cy + topH / 2 + ds * 0.36)}" font-size="${num(ds)}" font-weight="800" text-anchor="middle">${esc(v.number(n))}</text>`);
326
+ const lt = capitals(label);
327
+ const lines = labelLines(lt, cw - 10, lowH - 10);
328
+ lines.lines.forEach((line, li) => g.push(`<text class="jb-lab" x="${num(x0 + cw / 2)}" y="${num(cy + topH + (lowH - lines.height) / 2 + lines.size * (li + 0.88))}" font-size="${num(lines.size)}" font-weight="800" text-anchor="middle">${esc(line)}</text>`));
329
+ void id;
330
+ });
331
+ // The group is as tall as its plate and its stand, and sits in the middle of the room it has.
332
+ const contentH = standY + standH - rect.y;
333
+ const dy = Math.max(0, (rect.h - contentH) / 2);
334
+ if (winner === k)
335
+ g.push(`<rect class="jb-win" x="${num(rect.x - 3)}" y="${num(rect.y - 3)}" width="${num(rect.w + 6)}" height="${num(contentH + 6)}" rx="${rx}"/>`);
336
+ body.push(`<g class="${cls}${winner === k ? " jb-winner" : ""}" transform="translate(0 ${num(dy)})">${g.join("")}</g>`);
337
+ };
338
+ const place = options.style === "flip" ? flipGroup : panel;
339
+ place(rectA, firstSide);
340
+ place(rectB, secondSide);
341
+ // ---- The centre: golden score, the clock and the osaekomi timer.
342
+ const c = [];
343
+ const clockText = clockSeconds === null ? "–:––" : mmss(clockSeconds);
344
+ const goldH = rectC.h * (landscape ? 0.1 : 0.2);
345
+ const rxC = Math.round(unit * 0.012);
346
+ if (landscape) {
347
+ const clockH = rectC.h * 0.38;
348
+ const osaY = rectC.y + goldH + pad + clockH + pad;
349
+ const osaH = rectC.y + rectC.h - osaY;
350
+ c.push(...goldenBadge(rectC.x, rectC.y, rectC.w, goldH, contest.goldenScore === true, w.judoGoldenLong, rxC));
351
+ c.push(...clockBox(rectC.x, rectC.y + goldH + pad, rectC.w, clockH, clockText, contest.goldenScore === true, rxC));
352
+ c.push(...osaekomiBox({ x: rectC.x, y: osaY, w: rectC.w, h: osaH }, osa, [firstSide, secondSide], v, rxC, true));
353
+ }
354
+ else {
355
+ const stripH = rectC.h;
356
+ const gh = stripH * 0.2;
357
+ const clockW = rectC.w * 0.5;
358
+ c.push(...goldenBadge(rectC.x, rectC.y, clockW, gh, contest.goldenScore === true, w.judoGoldenLong, rxC));
359
+ c.push(...clockBox(rectC.x, rectC.y + gh + pad / 2, clockW, stripH - gh - pad / 2, clockText, contest.goldenScore === true, rxC));
360
+ c.push(...osaekomiBox({ x: rectC.x + clockW + pad, y: rectC.y, w: rectC.w - clockW - pad, h: stripH }, osa, [firstSide, secondSide], v, rxC, false));
361
+ }
362
+ body.push(`<g class="jb-centre">${c.join("")}</g>`);
363
+ // ---- The foot: the result, once there is one.
364
+ body.push(`<rect class="jb-band" x="0" y="${num(H - footH)}" width="${num(W)}" height="${num(footH)}"/>`);
365
+ if (winner !== null) {
366
+ const name = (winner === 0 ? contest.white : contest.blue).name;
367
+ const how = out.method ? judoMethodWord(out.method, v) : "";
368
+ const text = `${fill(w.winnerIs, { name })}${how ? ` · ${how}` : ""}${contest.goldenScore ? ` · ${w.judoGolden}` : ""}`;
369
+ const fs = clampN(footH * 0.46, 14, 64);
370
+ const dot = fs * 0.55;
371
+ const tw = Math.min(bw(text, fs), W - pad * 4 - dot * 2);
372
+ const size = tw < bw(text, fs) ? fs * (tw / bw(text, fs)) : fs;
373
+ const x0 = W / 2 - tw / 2 + dot * 1.5;
374
+ body.push(`<circle cx="${num(x0 - dot)}" cy="${num(H - footH / 2)}" r="${num(dot / 2)}" style="fill:${winner === 0 ? WHITE_PANEL.panel : BLUE_PANEL.panel};stroke:${winner === 0 ? WHITE_PANEL.edge : "#9db5ea"};stroke-width:2"/>`);
375
+ body.push(t(x0, H - footH / 2 + size * 0.36, size, text, "jb-ink", "start", 800));
376
+ }
377
+ const title = options.title ?? w.judoBoardTitle;
378
+ const desc = describeJudoContest(contest, options);
379
+ const defs = markDefs(ctx);
380
+ const scope = prefix;
381
+ return [
382
+ `<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(v.locale.code)}" class="taisen taisen-judo ${esc(scope)}${options.className ? ` ${esc(options.className)}` : ""}" viewBox="0 0 ${num(W)} ${num(H)}" width="${num(W)}" height="${num(H)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${esc(prefix)}-t ${esc(prefix)}-d">`,
383
+ `<title id="${esc(prefix)}-t">${esc(title)}</title><desc id="${esc(prefix)}-d">${esc(desc)}</desc>`,
384
+ `<style>${styleFor(scope, theme)}</style>`,
385
+ defs ? `<defs>${defs}</defs>` : "",
386
+ `<rect class="jb-bg" width="${num(W)}" height="${num(H)}"/>`,
387
+ ...body,
388
+ "</svg>",
389
+ ].filter(Boolean).join("\n");
390
+ }
391
+ /** The size a text can be set at to fit a width, from the size asked for. */
392
+ function fitSize(text, size, width, floor = 0.62) {
393
+ const need = bw(text, size);
394
+ return need <= width ? size : Math.max(size * (width / need), size * floor);
395
+ }
396
+ /** A card's label in the biggest size that fits, on one line or split at a hyphen or a space into two. */
397
+ function labelLines(text, maxW, maxH) {
398
+ const one = Math.min(maxH / 1.15, maxW / emWidth(text), 80);
399
+ let best = { lines: [text], size: one, height: one * 1.15 };
400
+ const at = [...text].map((ch, i) => (ch === "-" || ch === " " ? i : -1)).filter((i) => i >= 0);
401
+ for (const i of at) {
402
+ const a = text.slice(0, i + (text[i] === "-" ? 1 : 0)).trim(), b = text.slice(i + 1).trim();
403
+ const size = Math.min(maxH / 2.3, maxW / Math.max(emWidth(a), emWidth(b)), 80);
404
+ if (size > best.size * 1.15)
405
+ best = { lines: [a, b], size, height: size * 2.3 };
406
+ }
407
+ return best;
408
+ }
409
+ function goldenBadge(x, y, w, h, on, label, rx) {
410
+ if (!on)
411
+ return [];
412
+ const text = capitals(label);
413
+ const fs = fitSize(text, clampN(h * 0.5, 12, 48), w - 16);
414
+ return [`<rect class="jb-goldbox" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(rx)}"/>`, t2(x + w / 2, y + h / 2 + fs * 0.36, fs, fit(text, fs, w - 16, bw), "#1b1500", "middle", ' letter-spacing=".1em"')];
415
+ }
416
+ /** A text in a fixed colour (on a fixed-colour box, so no theme changes it). */
417
+ function t2(x, y, size, text, color, anchor, extra = "") {
418
+ return `<text x="${num(x)}" y="${num(y)}" font-size="${num(size)}" font-weight="800" text-anchor="${anchor}" style="fill:${color}"${extra}>${esc(text)}</text>`;
419
+ }
420
+ function clockBox(x, y, w, h, text, golden, rx) {
421
+ const fs = Math.min(h * 0.74, (w - 24) / emWidth(text, true));
422
+ return [
423
+ `<rect class="jb-box" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${num(rx)}"/>`,
424
+ ...(golden ? [`<rect class="jb-clockedge" x="${num(x + 2)}" y="${num(y + 2)}" width="${num(w - 4)}" height="${num(h - 4)}" rx="${num(rx)}"/>`] : []),
425
+ `<text class="${golden ? "jb-gold" : "jb-boxink"}" x="${num(x + w / 2)}" y="${num(y + h / 2 + fs * 0.36)}" font-size="${num(fs)}" font-weight="800" text-anchor="middle">${esc(text)}</text>`,
426
+ ];
427
+ }
428
+ /** The osaekomi timer: the seconds, and a bar to 20 with a tick at 5 (yuko), 10 (waza-ari) and 20 (ippon), filled toward the athlete who holds. */
429
+ function osaekomiBox(r, osa, order, v, rx, tall) {
430
+ const w = v.words;
431
+ const out = [`<rect class="jb-box" x="${num(r.x)}" y="${num(r.y)}" width="${num(r.w)}" height="${num(r.h)}" rx="${num(rx)}"/>`];
432
+ const pad = Math.max(12, r.w * 0.06);
433
+ const labelS = clampN(Math.min(r.h * (tall ? 0.13 : 0.14), r.w * 0.09), 11, 34);
434
+ const label = capitals(w.judoOsaekomi);
435
+ out.push(`<text class="jb-boxink" x="${num(r.x + r.w / 2)}" y="${num(r.y + pad * 0.6 + labelS)}" font-size="${num(fitSize(label, labelS, r.w - pad * 2))}" font-weight="800" text-anchor="middle" letter-spacing=".08em" opacity="${osa ? 1 : 0.55}">${esc(label)}</text>`);
436
+ const seconds = osa ? Math.min(Math.floor(osa.seconds), 20) : null;
437
+ const barH = clampN(r.h * 0.11, 10, 34);
438
+ const barX = r.x + pad, barW = r.w - pad * 2;
439
+ const barY = r.y + r.h - pad - barH - labelS * 1.2;
440
+ const numberH = barY - (r.y + pad * 0.6 + labelS) - pad * 0.5;
441
+ const fs = Math.min(numberH * 0.92, barW * 0.5);
442
+ const shown = seconds === null ? "–" : v.number(seconds);
443
+ out.push(`<text class="jb-boxink" x="${num(r.x + r.w / 2)}" y="${num(barY - pad * 0.5 - (numberH - fs * 0.72) / 2)}" font-size="${num(fs)}" font-weight="800" text-anchor="middle" opacity="${osa ? 1 : 0.4}">${esc(shown)}</text>`);
444
+ out.push(`<rect class="jb-track" x="${num(barX)}" y="${num(barY)}" width="${num(barW)}" height="${num(barH)}" rx="${num(barH / 2)}"/>`);
445
+ if (osa && seconds !== null && seconds > 0) {
446
+ // The bar grows from the side of the holder: toward the left when the holder is on the left.
447
+ const left = order[0] === osa.side;
448
+ const fw = barW * (seconds / 20);
449
+ out.push(`<g class="${osa.side === 0 ? "jb-w" : "jb-b"}"><rect class="jb-fillside" x="${num(left ? barX : barX + barW - fw)}" y="${num(barY)}" width="${num(fw)}" height="${num(barH)}" rx="${num(barH / 2)}"/></g>`);
450
+ }
451
+ // The ticks: 5, 10 and 20 seconds, as the marks of yuko, waza-ari and ippon.
452
+ const marks = [[5, w.judoYuko], [10, w.judoWazaAri], [20, w.judoIppon]];
453
+ const left = osa ? order[0] === osa.side : true;
454
+ for (const [sec, mark] of marks) {
455
+ const px = left ? barX + barW * (sec / 20) : barX + barW - barW * (sec / 20);
456
+ out.push(`<line class="jb-tick" x1="${num(px)}" x2="${num(px)}" y1="${num(barY - 4)}" y2="${num(barY + barH + 4)}"/>`);
457
+ out.push(`<text class="jb-boxink" x="${num(Math.min(Math.max(px, barX + labelS * 0.7), barX + barW - labelS * 0.7))}" y="${num(barY + barH + 6 + labelS * 0.9)}" font-size="${num(labelS * 0.9)}" font-weight="800" text-anchor="middle" opacity=".8">${esc(mark)}</text>`);
458
+ }
459
+ return out;
460
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Judo results: the scores of one contest and what they come to under the IJF Sport and Organisation Rules (Refereeing Rules, version
3
+ * 21 January 2026, for the Olympic cycle to Los Angeles 2028). Pure and sport-specific; nothing here draws. The rules this follows, by
4
+ * article, are in docs/judo.md.
5
+ *
6
+ * - Article 7 and 15: osaekomi of 20 seconds is ippon, 10 to 19 seconds waza-ari, 5 to 9 seconds yuko.
7
+ * - Article 15: "Yuko scores are counted (1, 2, 3, etc.) but they do not add up to waza-ari."
8
+ * - Article 16: a second waza-ari is waza-ari-awasete-ippon, ippon.
9
+ * - Article 18: "Two shido can be awarded and the third will be hansoku-make"; "A contest cannot be decided by single or accumulated shido, until hansoku-make is reached."
10
+ * - Article 13: a level contest at the end of regular time (4 minutes) goes to golden score, which has no time limit and ends at the first score or hansoku-make.
11
+ */
12
+ /** How a contest was won. `"score"` is a decision on the technical scores (the waza-ari, then the yuko, counted). */
13
+ export type JudoMethod = "ippon" | "waza-ari-awasete-ippon" | "score" | "hansoku-make" | "fusen-gachi" | "kiken-gachi";
14
+ /** What one side scored in a contest. Anything left out is none. */
15
+ export interface JudoSide {
16
+ /** Ippon: the contest ends. */
17
+ ippon?: boolean;
18
+ /** Waza-ari, 0 to 2; a second one is waza-ari-awasete-ippon. */
19
+ wazaAri?: number;
20
+ /** Yuko: counted, never added up to a waza-ari. */
21
+ yuko?: number;
22
+ /** Shido (the penalties), 0 to 3; the third is hansoku-make (indirect). */
23
+ shido?: number;
24
+ }
25
+ /** The result of one judo contest, the sides in the order of the matchup's `sides`: the first is white, the second blue. */
26
+ export interface JudoResult {
27
+ sides: readonly [JudoSide, JudoSide];
28
+ /** How it was won. Left out, it is worked out from the scores; fusen-gachi and kiken-gachi (no score can say so) need the matchup's `winner`. */
29
+ method?: JudoMethod;
30
+ /** The side (0 or 1) that was given a direct hansoku-make (a grave infringement): the other side wins by ippon. */
31
+ hansokuMake?: 0 | 1;
32
+ /** The contest went on to golden score, and the scores are the ones at its end. */
33
+ goldenScore?: boolean;
34
+ /** The contest time: seconds (`222`) or `"3:42"`. Regular time is 4 minutes; golden score runs past it. */
35
+ time?: number | string;
36
+ }
37
+ /** A result checked and worked out. */
38
+ export interface JudoOutcome {
39
+ /** 0 or 1, the side that won; `null` while nothing has decided it (level scores: golden score is next). */
40
+ winner: 0 | 1 | null;
41
+ /** How it was won, or `null` while undecided. */
42
+ method: JudoMethod | null;
43
+ goldenScore: boolean;
44
+ /** The contest time in seconds, or `null`. */
45
+ seconds: number | null;
46
+ /** The two sides with every count filled in. */
47
+ sides: readonly [Required<JudoSide>, Required<JudoSide>];
48
+ /** The value on the scoreboard (Articles 4 and 15: ippon 100, waza-ari 10, yuko 1; two waza-ari are ippon). */
49
+ points: readonly [number, number];
50
+ /** Both sides were given the third shido: the contest is recorded 0-0 and nobody wins (Article 18.3.1). */
51
+ doubleHansokuMake: boolean;
52
+ }
53
+ /** The IJF's senior weight classes in kilograms, the lightest first: a number is "up to and including", a text starting `+` is "over". */
54
+ export declare const JUDO_WEIGHT_CLASSES: Readonly<Record<"men" | "women", readonly string[]>>;
55
+ /** A time as seconds: a number, or `"m:ss"`. */
56
+ export declare function judoSeconds(time: number | string | undefined): number | null;
57
+ /** Seconds as `m:ss`. */
58
+ export declare const judoClock: (seconds: number) => string;
59
+ /**
60
+ * Checks a result against the rules and works out who won and how. `winner` is the winner the matchup names (needed for fusen-gachi and
61
+ * kiken-gachi, which no score can show). Throws a `RangeError` for a count out of range, two ippon, a method the scores contradict, or a
62
+ * default with no winner.
63
+ */
64
+ export declare function judoOutcome(result: JudoResult, winner?: 0 | 1 | null): JudoOutcome;
65
+ /** Whether a result has any score or penalty at all (a contest still to be fought has none). */
66
+ export declare const judoHasScore: (o: JudoOutcome) => boolean;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Judo results: the scores of one contest and what they come to under the IJF Sport and Organisation Rules (Refereeing Rules, version
3
+ * 21 January 2026, for the Olympic cycle to Los Angeles 2028). Pure and sport-specific; nothing here draws. The rules this follows, by
4
+ * article, are in docs/judo.md.
5
+ *
6
+ * - Article 7 and 15: osaekomi of 20 seconds is ippon, 10 to 19 seconds waza-ari, 5 to 9 seconds yuko.
7
+ * - Article 15: "Yuko scores are counted (1, 2, 3, etc.) but they do not add up to waza-ari."
8
+ * - Article 16: a second waza-ari is waza-ari-awasete-ippon, ippon.
9
+ * - Article 18: "Two shido can be awarded and the third will be hansoku-make"; "A contest cannot be decided by single or accumulated shido, until hansoku-make is reached."
10
+ * - Article 13: a level contest at the end of regular time (4 minutes) goes to golden score, which has no time limit and ends at the first score or hansoku-make.
11
+ */
12
+ /** The IJF's senior weight classes in kilograms, the lightest first: a number is "up to and including", a text starting `+` is "over". */
13
+ export const JUDO_WEIGHT_CLASSES = Object.freeze({
14
+ men: Object.freeze(["-60", "-66", "-73", "-81", "-90", "-100", "+100"]),
15
+ women: Object.freeze(["-48", "-52", "-57", "-63", "-70", "-78", "+78"]),
16
+ });
17
+ const whole = (what, n, max) => {
18
+ const v = n === undefined ? 0 : n;
19
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > max)
20
+ throw new RangeError(`taisen-brackets: ${what} is a whole number from 0 to ${max}, not ${JSON.stringify(v)}`);
21
+ return v;
22
+ };
23
+ function filled(side, k) {
24
+ const who = k === 0 ? "the white side's" : "the blue side's";
25
+ return {
26
+ ippon: side.ippon === true,
27
+ wazaAri: whole(`${who} waza-ari`, side.wazaAri, 2),
28
+ yuko: whole(`${who} yuko`, side.yuko, 99),
29
+ shido: whole(`${who} shido`, side.shido, 3),
30
+ };
31
+ }
32
+ /** A time as seconds: a number, or `"m:ss"`. */
33
+ export function judoSeconds(time) {
34
+ if (time === undefined)
35
+ return null;
36
+ if (typeof time === "number") {
37
+ if (!Number.isFinite(time) || time < 0)
38
+ throw new RangeError(`taisen-brackets: a judo contest time is seconds from 0, not ${time}`);
39
+ return Math.round(time);
40
+ }
41
+ const m = /^(\d{1,3}):([0-5]\d)$/.exec(time.trim());
42
+ if (!m)
43
+ throw new RangeError(`taisen-brackets: a judo contest time is seconds or "m:ss" ("3:42"), not ${JSON.stringify(time)}`);
44
+ return Number(m[1]) * 60 + Number(m[2]);
45
+ }
46
+ /** Seconds as `m:ss`. */
47
+ export const judoClock = (seconds) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
48
+ const scoreboard = (s) => (s.ippon || s.wazaAri >= 2 ? 100 : s.wazaAri * 10 + s.yuko);
49
+ /**
50
+ * Checks a result against the rules and works out who won and how. `winner` is the winner the matchup names (needed for fusen-gachi and
51
+ * kiken-gachi, which no score can show). Throws a `RangeError` for a count out of range, two ippon, a method the scores contradict, or a
52
+ * default with no winner.
53
+ */
54
+ export function judoOutcome(result, winner = null) {
55
+ const sides = [filled(result.sides[0], 0), filled(result.sides[1], 1)];
56
+ const seconds = judoSeconds(result.time);
57
+ const points = [scoreboard(sides[0]), scoreboard(sides[1])];
58
+ const golden = result.goldenScore === true;
59
+ const out = (w, method, double = false) => ({ winner: w, method, goldenScore: golden, seconds, sides, points, doubleHansokuMake: double });
60
+ const other = (k) => (k === 0 ? 1 : 0);
61
+ if (sides[0].ippon && sides[1].ippon)
62
+ throw new RangeError("taisen-brackets: both sides of a judo contest cannot score ippon");
63
+ if (sides[0].wazaAri === 2 && sides[1].wazaAri === 2)
64
+ throw new RangeError("taisen-brackets: both sides of a judo contest cannot score waza-ari-awasete-ippon");
65
+ const given = result.method;
66
+ const agree = (derived) => {
67
+ if (given !== undefined && derived.method !== given)
68
+ throw new RangeError(`taisen-brackets: the judo method ${JSON.stringify(given)} does not fit the scores, which make ${JSON.stringify(derived.method)}`);
69
+ return derived;
70
+ };
71
+ if (given === "fusen-gachi" || given === "kiken-gachi") {
72
+ if (winner === null)
73
+ throw new RangeError(`taisen-brackets: a judo contest won by ${given} needs the matchup's winner`);
74
+ return out(winner, given);
75
+ }
76
+ if (result.hansokuMake !== undefined) {
77
+ if (result.hansokuMake !== 0 && result.hansokuMake !== 1)
78
+ throw new RangeError("taisen-brackets: hansokuMake is 0 or 1, the side that was penalised");
79
+ return agree(out(other(result.hansokuMake), "hansoku-make"));
80
+ }
81
+ if (sides[0].shido >= 3 && sides[1].shido >= 3)
82
+ return agree(out(null, "hansoku-make", true));
83
+ for (const k of [0, 1])
84
+ if (sides[k].shido >= 3)
85
+ return agree(out(other(k), "hansoku-make"));
86
+ for (const k of [0, 1]) {
87
+ if (sides[k].ippon)
88
+ return agree(out(k, "ippon"));
89
+ if (sides[k].wazaAri >= 2)
90
+ return agree(out(k, "waza-ari-awasete-ippon"));
91
+ }
92
+ const by = (a, b) => (a > b ? 0 : b > a ? 1 : null);
93
+ const decided = by(sides[0].wazaAri, sides[1].wazaAri) ?? by(sides[0].yuko, sides[1].yuko);
94
+ if (decided !== null)
95
+ return agree(out(decided, "score"));
96
+ if (given !== undefined)
97
+ throw new RangeError(`taisen-brackets: the judo method ${JSON.stringify(given)} does not fit scores that are level`);
98
+ // Level: nothing has decided it, though the matchup may name a winner (a contest recorded with no scores).
99
+ return out(null, null);
100
+ }
101
+ /** Whether a result has any score or penalty at all (a contest still to be fought has none). */
102
+ export const judoHasScore = (o) => o.sides.some((s) => s.ippon || s.wazaAri > 0 || s.yuko > 0 || s.shido > 0);