@johnmorrisdotca/taisen-brackets 0.7.0 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/judo-board.d.ts +87 -0
- package/dist/judo-board.js +460 -0
- package/dist/locales.js +5 -0
- package/dist/types.d.ts +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ All notable changes to this project are written here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.8.0] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
Judo, step 2: the scoreboard of one contest.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`judoScoreboard(contest, options)`**: one judo contest's scoreboard as an SVG string for the state given. The two athletes (white and blue) with their flags and three-letter codes, the ippon, waza-ari and yuko counters, the shido cards (the third red), the contest clock, a golden score badge, the osaekomi timer (a bar to 20 seconds with ticks at 5, 10 and 20, the IJF's yuko, waza-ari and ippon times, filled from the holder's side), the weight class, the round, the mat and the contest number, and, when something has ended the contest, the winner and how at the foot. The state is checked like a bracket's judo result (`JudoContest`, `JudoAthlete`); a lead on the scores wins only once `finished`.
|
|
16
|
+
- **Sizes**: `tv` (1920 by 1080), `tablet` (1280 by 800), `tablet-tall` (800 by 1280) or your own `{ width, height }`; light, dark or `auto`; `swap` (blue first); `coaches`; `flags` and `flagHref` as for `drawBracket`; five languages. `JUDO_BOARD_SIZES`.
|
|
17
|
+
- **`style: "flip"`**: the same state as a table-top flip scoreboard, a row of ring-bound cards on a stand for each side, one card to a counter (ippon, waza-ari, yuko, shido, hansoku-make), the numeral on the upper flap and the label on the lower, white and blue cards.
|
|
18
|
+
- **`describeJudoContest(contest, options)`**: the state as a sentence, the drawing's description.
|
|
19
|
+
- Words for the scoreboard in the five languages (Osaekomi, Shido, Mat, Contest, Coach, the title); the French and Japanese were read by native-language reviewers (the Japanese osaekomi is written with the Kodokan's own character, not the everyday one), the Spanish and German were not.
|
|
20
|
+
- The demo has a Judo scoreboard section: counters with a plus and a minus, the clock, the osaekomi side and seconds, golden score, finished, blue first, style, size and look, and the call that draws it; the state is in the address and nowhere else. Geometry tests hold every text inside the drawing, and every name inside its panel, in every size, look and several languages.
|
|
21
|
+
|
|
9
22
|
## [0.7.0] - 2026-10-20
|
|
10
23
|
|
|
11
24
|
Judo, step 1: results in the bracket boxes, and the bracket of a weight category with the repechage and two bronze medals. Nothing changes for a bracket that has no judo result (every existing snapshot is unchanged).
|
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ In the road look, `seeds: true` writes a small tag (`P1`, `WC2`) beside each tea
|
|
|
240
240
|
|
|
241
241
|
## Judo
|
|
242
242
|
|
|
243
|
-
A judo contest is a tie with a `judo` result: ippon, waza-ari, yuko and shido as the IJF's current Sport and Organisation Rules score them (yuko is back since 2025), golden score, the contest time and how it was won. The box writes the marks (`I`, `W`, `Y`), the shido as yellow and red cards and a note under the two rows, in the five languages. `judoBracket` makes a weight category's bracket with the IJF's repechage and its two bronze medal contests. [docs/judo.md](./docs/judo.md) quotes the rules it follows.
|
|
243
|
+
A judo contest is a tie with a `judo` result: ippon, waza-ari, yuko and shido as the IJF's current Sport and Organisation Rules score them (yuko is back since 2025), golden score, the contest time and how it was won. The box writes the marks (`I`, `W`, `Y`), the shido as yellow and red cards and a note under the two rows, in the five languages. `judoBracket` makes a weight category's bracket with the IJF's repechage and its two bronze medal contests. [docs/judo.md](./docs/judo.md) quotes the rules it follows. `judoScoreboard` draws one contest's scoreboard for a given state (the athletes with flags and codes, ippon, waza-ari and yuko counters, shido cards, clock, golden score, osaekomi timer, weight class and round), on a TV or a tablet, as a screen board or as table-top flip cards: [try it in the demo](https://johnmorrisdotca.github.io/taisen-brackets/#board).
|
|
244
244
|
|
|
245
245
|
<p align="center"><picture>
|
|
246
246
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/taisen-brackets/main/docs/images/judo-desk-dark.webp">
|
|
@@ -292,6 +292,7 @@ Put the string in a page (`element.innerHTML = svg`), write it to a file, or sen
|
|
|
292
292
|
- **Team marks you supply**: a logo address or inline SVG, drawn round or square; a team with none is its initials on a colour. Round flags from [Hata](https://github.com/johnmorrisdotca/hata) go in as they are.
|
|
293
293
|
- **The champion in the centre**, with a slot for your trophy: your own SVG or image, or an emblem from [Dougu Sports](https://github.com/johnmorrisdotca/dougu-sports) (a hockey cup, a basketball trophy, a globe) through an optional plug-in, with no dependency added.
|
|
294
294
|
- **Accessible**: `role="img"` with a title and a description, a tooltip sentence on every tie, and `bracketTable`.
|
|
295
|
+
- **A judo scoreboard**: `judoScoreboard` draws one contest for a given state, in two styles (a board, and table-top flip cards) and three sizes, in five languages; live updating is yours.
|
|
295
296
|
- **Judo scoring**: `Matchup.judo` takes ippon, waza-ari, yuko, shido, hansoku-make, golden score and the contest time, checked against the IJF rules (`judoOutcome`); a box shows the marks, the shido cards and how the contest was won, and `judoBracket` adds the repechage and the two bronze medal contests ([docs/judo.md](./docs/judo.md)).
|
|
296
297
|
- **Formats by era**: NHL, NBA, MLB, NFL, CFL, MLS and FIFA World Cup round names, series lengths and byes.
|
|
297
298
|
- **Five languages**: English, French, Japanese, Spanish and German, for every word the drawing, the tooltips, the description and the table write, with the football terms of each language; countries named by `Intl` from their ISO code; numbers and dates by `Intl`. Add a language of your own.
|
|
@@ -652,7 +653,7 @@ Everything is exported from `@johnmorrisdotca/taisen-brackets`; the layout part
|
|
|
652
653
|
- `singleElimination(teams, options?)`, `advanceWinners(matchups)`, `standardSeeds(size)`: brackets for any number of teams.
|
|
653
654
|
- `matchupsFromGames(games, options)`: matchups from a flat list of games.
|
|
654
655
|
- `resolveMatchup`, `collectTeams`, `describeTie`, `noteParts`, `noteLong`: one tie's result, the teams of a bracket, a tie in a sentence.
|
|
655
|
-
- `judoOutcome(result, winner?)`, `judoBracket(athletes, options?)`, `judoPodium(input)`, `judoCategoryLabel(category, locale?)`, `JUDO_WEIGHT_CLASSES`, `judoSeconds(time)`, `judoClock(seconds)`: judo results checked against the IJF rules, the bracket with its repechage and two bronze medal contests, the medallists, a weight category as a heading ([docs/judo.md](./docs/judo.md)). `Matchup.judo` and `Matchup.stage` (`"repechage"`, `"bronze"`) carry them into `drawBracket`.
|
|
656
|
+
- `judoOutcome(result, winner?)`, `judoBracket(athletes, options?)`, `judoPodium(input)`, `judoCategoryLabel(category, locale?)`, `JUDO_WEIGHT_CLASSES`, `judoSeconds(time)`, `judoClock(seconds)`: judo results checked against the IJF rules, the bracket with its repechage and two bronze medal contests, the medallists, a weight category as a heading ([docs/judo.md](./docs/judo.md)). `judoScoreboard(contest, options?)`, `describeJudoContest(contest, options?)`, `JUDO_BOARD_SIZES`: the scoreboard of a contest as SVG and as a sentence. `Matchup.judo` and `Matchup.stage` (`"repechage"`, `"bronze"`) carry them into `drawBracket`.
|
|
656
657
|
- `playoffFormat(league, year)`, `playoffEras(league)`, `LEAGUES`: the formats by era (`nhl`, `nba`, `mlb`, `nfl`, `cfl`, `mls`, `worldcup`). `LEAGUE_EMBLEMS`: each league's default emblem id.
|
|
657
658
|
- `isEmblemId(value)`, and the types `EmblemRenderer`, `EmblemRequest`, `EmblemStyle`: the trophy emblems (see "Emblems for the trophy").
|
|
658
659
|
- `roundName(remaining)`, `roundNamesFor(count)`, `roundLabel(layout, round, names, words)`: round names.
|
|
@@ -744,7 +745,7 @@ Any current browser, and Node 22 or later. The package makes no network request
|
|
|
744
745
|
|
|
745
746
|
## Roadmap
|
|
746
747
|
|
|
747
|
-
- Judo: a contest
|
|
748
|
+
- Judo: a contest card and a mat queue board, the road to the final of a judoka, double and full repechage ([docs/judo.md](./docs/judo.md)).
|
|
748
749
|
- Double elimination, and the group tables on one page with the knockout they lead to.
|
|
749
750
|
- More languages (Portuguese, Italian, Korean, Chinese), and native review of the Spanish and German words.
|
|
750
751
|
- A custom element and a React helper, as in the other packages of the family.
|
package/dist/index.d.ts
CHANGED
|
@@ -19,3 +19,5 @@ export { playoffFormat, playoffEras, LEAGUES, LEAGUE_EMBLEMS } from "./presets.t
|
|
|
19
19
|
export type { League, PlayoffFormat, PlayoffRound } from "./presets.ts";
|
|
20
20
|
export { judoBracket, judoPodium, judoCategoryLabel, judoOutcome, judoSeconds, judoClock, JUDO_WEIGHT_CLASSES } from "./judo.ts";
|
|
21
21
|
export type { JudoBracketOptions, JudoCategory, JudoMethod, JudoOutcome, JudoPodium, JudoResult, JudoSide, JudoSystem } from "./judo.ts";
|
|
22
|
+
export { judoScoreboard, describeJudoContest, JUDO_BOARD_SIZES } from "./judo-board.ts";
|
|
23
|
+
export type { JudoAthlete, JudoBoardOptions, JudoBoardSizeName, JudoContest } from "./judo-board.ts";
|
package/dist/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { resolveBackground } from "./background.js";
|
|
|
11
11
|
export { VERSION } from "./version.js";
|
|
12
12
|
export { playoffFormat, playoffEras, LEAGUES, LEAGUE_EMBLEMS } from "./presets.js";
|
|
13
13
|
export { judoBracket, judoPodium, judoCategoryLabel, judoOutcome, judoSeconds, judoClock, JUDO_WEIGHT_CLASSES } from "./judo.js";
|
|
14
|
+
export { judoScoreboard, describeJudoContest, JUDO_BOARD_SIZES } from "./judo-board.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scoreboard of one judo contest as a static SVG for a given state: the two athletes (white and blue) with their flags and codes, the ippon, waza-ari and yuko
|
|
3
|
+
* counters, the shido cards, the contest clock, the golden score badge, the osaekomi timer, the weight class, the round and the mat. Live updating is the caller's job:
|
|
4
|
+
* draw again with the new state. A pure function of its input and options, like every drawing here.
|
|
5
|
+
*/
|
|
6
|
+
import type { DrawOptions, Locale, ThemeName, Words } from "./types.ts";
|
|
7
|
+
import { type JudoMethod, type JudoSide } from "./judo-model.ts";
|
|
8
|
+
/** One athlete on the scoreboard. Only `name` is needed. */
|
|
9
|
+
export interface JudoAthlete {
|
|
10
|
+
name: string;
|
|
11
|
+
/** The country's three letters (`JPN`), written big under the name. Left out, nothing is written. */
|
|
12
|
+
code?: string;
|
|
13
|
+
/** A country code for the `flags` renderer (`jp`, `GB-ENG`: Hata's round flags are one line). With none, the flag is the code (or the initials) on a tile. */
|
|
14
|
+
flag?: string;
|
|
15
|
+
/** A mark of your own (an image address or inline SVG) in place of a flag. */
|
|
16
|
+
mark?: string;
|
|
17
|
+
/** The coach's name, written small under the code when `coaches` is on. */
|
|
18
|
+
coach?: string;
|
|
19
|
+
}
|
|
20
|
+
/** The state of a contest: who is on the mat, what has been scored, and the clock. */
|
|
21
|
+
export interface JudoContest {
|
|
22
|
+
white: JudoAthlete;
|
|
23
|
+
blue: JudoAthlete;
|
|
24
|
+
/** What each side has scored, white first. Left out, nothing yet. */
|
|
25
|
+
sides?: readonly [JudoSide, JudoSide];
|
|
26
|
+
/** The contest clock as it is shown: seconds (`222`) or `"3:42"`. */
|
|
27
|
+
clock?: number | string;
|
|
28
|
+
/** The contest is in golden score. */
|
|
29
|
+
goldenScore?: boolean;
|
|
30
|
+
/** An osaekomi in progress: the side holding (0 white, 1 blue) and the seconds so far (0 to 20; 5 is yuko, 10 waza-ari, 20 ippon). */
|
|
31
|
+
osaekomi?: {
|
|
32
|
+
side: 0 | 1;
|
|
33
|
+
seconds: number;
|
|
34
|
+
};
|
|
35
|
+
/** The weight class as a heading (`judoCategoryLabel`), the round ("Final", `roundName`), the mat (`1`, shown as "Mat 1") and the contest number (`"G-24"`). */
|
|
36
|
+
weight?: string;
|
|
37
|
+
round?: string;
|
|
38
|
+
mat?: string | number;
|
|
39
|
+
contest?: string | number;
|
|
40
|
+
/** The contest is over: a lead on the scores then wins by decision. An ippon, waza-ari-awasete-ippon, hansoku-make, fusen-gachi or kiken-gachi ends it by itself. */
|
|
41
|
+
finished?: boolean;
|
|
42
|
+
/** How it ended, for what the scores cannot say (see `JudoResult.method`), and who won a default (`0` white, `1` blue). */
|
|
43
|
+
method?: JudoMethod;
|
|
44
|
+
winner?: 0 | 1;
|
|
45
|
+
/** A direct hansoku-make: the side (0 white, 1 blue) that was given it. */
|
|
46
|
+
hansokuMake?: 0 | 1;
|
|
47
|
+
}
|
|
48
|
+
export type JudoBoardSizeName = "tv" | "tablet" | "tablet-tall";
|
|
49
|
+
/** The sizes: a TV's 1920 by 1080, a tablet on its side, 1280 by 800, and a tablet held upright, 800 by 1280. */
|
|
50
|
+
export declare const JUDO_BOARD_SIZES: Readonly<Record<JudoBoardSizeName, {
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
}>>;
|
|
54
|
+
export interface JudoBoardOptions {
|
|
55
|
+
/** `tv` (the default), `tablet`, `tablet-tall`, or a size of your own: a wider-than-tall one lays the sides out left and right, a taller one top and bottom. */
|
|
56
|
+
size?: JudoBoardSizeName | {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
};
|
|
60
|
+
/** `auto` follows the page's light or dark; the athletes' panels are the colours of the judogi either way. Default `auto`. */
|
|
61
|
+
theme?: Exclude<ThemeName, "poster" | "none">;
|
|
62
|
+
/** `board` (the default): two panels with the counters as tiles, like a screen scoreboard. `flip`: a row of ring-bound flip cards on a stand for each side, one card to a counter (ippon, waza-ari, yuko, shido, hansoku-make) with its label printed on it, like a table-top scoreboard. */
|
|
63
|
+
style?: "board" | "flip";
|
|
64
|
+
/** Draw blue first (on the left, or at the top) in place of white. Default false. */
|
|
65
|
+
swap?: boolean;
|
|
66
|
+
/** Write each athlete's coach under the code. Default true. */
|
|
67
|
+
coaches?: boolean;
|
|
68
|
+
/** Draws a country's flag from its code, as for `drawBracket`: a synchronous function from a code and `{ shape, size, theme }` to an SVG string, such as one made from `@johnmorrisdotca/hata`. */
|
|
69
|
+
flags?: DrawOptions["flags"];
|
|
70
|
+
/** The address of a flag as a picture to load, tried where `flags` gives none. */
|
|
71
|
+
flagHref?: DrawOptions["flagHref"];
|
|
72
|
+
/** The language: `en`, `fr`, `ja`, `es`, `de` or a `Locale`. */
|
|
73
|
+
locale?: string | Locale;
|
|
74
|
+
words?: Partial<Words>;
|
|
75
|
+
/** The `<title>` of the drawing. Default the language's "Judo contest scoreboard". */
|
|
76
|
+
title?: string;
|
|
77
|
+
/** Prefix for the drawing's ids. Default: a hash of the state and the options. */
|
|
78
|
+
id?: string;
|
|
79
|
+
className?: string;
|
|
80
|
+
}
|
|
81
|
+
/** The words of the state in a sentence, for the drawing's description. */
|
|
82
|
+
export declare function describeJudoContest(contest: JudoContest, options?: Pick<JudoBoardOptions, "locale" | "words">): string;
|
|
83
|
+
/**
|
|
84
|
+
* 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
|
|
85
|
+
* past 20 seconds is clamped to 20).
|
|
86
|
+
*/
|
|
87
|
+
export declare function judoScoreboard(contest: JudoContest, options?: JudoBoardOptions): string;
|
|
@@ -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
|
+
}
|
package/dist/locales.js
CHANGED
|
@@ -26,6 +26,7 @@ const EN = {
|
|
|
26
26
|
judoGolden: "GS", judoGoldenLong: "golden score", judoTime: "{t}", judoShidoOf: "{name}: {n} shido", judoShidoShort: "shido {a}–{b}",
|
|
27
27
|
judoRepechage: "Repechage", judoBronze: "Bronze medal contest", judoRepechageBronze: "Repechage and bronze medal contests", judoWhite: "white", judoBlue: "blue",
|
|
28
28
|
judoWeightClass: "{sex} -{w} kg", judoWeightOver: "{sex} +{w} kg", judoMen: "Men", judoWomen: "Women",
|
|
29
|
+
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Mat {n}", judoContest: "Contest {n}", judoCoach: "Coach: {name}", judoBoardTitle: "Judo contest scoreboard",
|
|
29
30
|
};
|
|
30
31
|
const FR = {
|
|
31
32
|
tbd: "À déterminer", bye: "exempt", walkover: "forfait", champion: "Champion", thirdPlace: "Match pour la troisième place", other: "Autres rencontres",
|
|
@@ -48,6 +49,7 @@ const FR = {
|
|
|
48
49
|
judoGolden: "GS", judoGoldenLong: "golden score", judoTime: "{t}", judoShidoOf: "{name}\u00a0: {n}\u00a0shido", judoShidoShort: "shido {a}–{b}",
|
|
49
50
|
judoRepechage: "Repêchage", judoBronze: "Combat pour la médaille de bronze", judoRepechageBronze: "Repêchages et combats pour la médaille de bronze", judoWhite: "blanc", judoBlue: "bleu",
|
|
50
51
|
judoWeightClass: "{sex} -{w}\u00a0kg", judoWeightOver: "{sex} +{w}\u00a0kg", judoMen: "Hommes", judoWomen: "Femmes",
|
|
52
|
+
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Tapis {n}", judoContest: "Combat {n}", judoCoach: "Entraîneur\u00a0: {name}", judoBoardTitle: "Tableau d’affichage d’un combat de judo",
|
|
51
53
|
};
|
|
52
54
|
const JA = {
|
|
53
55
|
tbd: "未定", bye: "シード", walkover: "不戦勝", champion: "優勝", thirdPlace: "3位決定戦", other: "その他の対戦",
|
|
@@ -70,6 +72,7 @@ const JA = {
|
|
|
70
72
|
judoGolden: "GS", judoGoldenLong: "ゴールデンスコア", judoTime: "{t}", judoShidoOf: "{name}:指導{n}", judoShidoShort: "指導{a}–{b}",
|
|
71
73
|
judoRepechage: "敗者復活戦", judoBronze: "銅メダル決定戦", judoRepechageBronze: "敗者復活戦と銅メダル決定戦", judoWhite: "白", judoBlue: "青",
|
|
72
74
|
judoWeightClass: "{sex}{w}kg級", judoWeightOver: "{sex}{w}kg超級", judoMen: "男子", judoWomen: "女子",
|
|
75
|
+
judoOsaekomi: "抑え込み", judoShido: "指導", judoMat: "第{n}試合場", judoContest: "試合 {n}", judoCoach: "コーチ:{name}", judoBoardTitle: "柔道試合のスコアボード",
|
|
73
76
|
};
|
|
74
77
|
const ES = {
|
|
75
78
|
tbd: "Por definir", bye: "exento", walkover: "incomparecencia", champion: "Campeón", thirdPlace: "Partido por el tercer puesto", other: "Otros partidos",
|
|
@@ -92,6 +95,7 @@ const ES = {
|
|
|
92
95
|
judoGolden: "GS", judoGoldenLong: "golden score", judoTime: "{t}", judoShidoOf: "{name}: {n} shido", judoShidoShort: "shido {a}–{b}",
|
|
93
96
|
judoRepechage: "Repesca", judoBronze: "Combate por la medalla de bronce", judoRepechageBronze: "Repesca y combates por la medalla de bronce", judoWhite: "blanco", judoBlue: "azul",
|
|
94
97
|
judoWeightClass: "{sex} -{w} kg", judoWeightOver: "{sex} +{w} kg", judoMen: "Hombres", judoWomen: "Mujeres",
|
|
98
|
+
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Tatami {n}", judoContest: "Combate {n}", judoCoach: "Entrenador: {name}", judoBoardTitle: "Marcador de un combate de judo",
|
|
95
99
|
};
|
|
96
100
|
const DE = {
|
|
97
101
|
tbd: "offen", bye: "Freilos", walkover: "kampflos", champion: "Turniersieger", thirdPlace: "Spiel um Platz 3", other: "Weitere Begegnungen",
|
|
@@ -114,6 +118,7 @@ const DE = {
|
|
|
114
118
|
judoGolden: "GS", judoGoldenLong: "Golden Score", judoTime: "{t}", judoShidoOf: "{name}: {n} Shido", judoShidoShort: "Shido {a}–{b}",
|
|
115
119
|
judoRepechage: "Hoffnungsrunde", judoBronze: "Kampf um Bronze", judoRepechageBronze: "Hoffnungsrunde und Kämpfe um Bronze", judoWhite: "weiß", judoBlue: "blau",
|
|
116
120
|
judoWeightClass: "{sex} -{w} kg", judoWeightOver: "{sex} +{w} kg", judoMen: "Herren", judoWomen: "Damen",
|
|
121
|
+
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Matte {n}", judoContest: "Kampf {n}", judoCoach: "Trainer: {name}", judoBoardTitle: "Anzeigetafel eines Judokampfes",
|
|
117
122
|
};
|
|
118
123
|
/**
|
|
119
124
|
* The round names, English name first. The football rounds are the terms of each language's Wikipedia article on the World Cup;
|
package/dist/types.d.ts
CHANGED
|
@@ -385,6 +385,13 @@ export interface Words {
|
|
|
385
385
|
judoWeightOver: string;
|
|
386
386
|
judoMen: string;
|
|
387
387
|
judoWomen: string;
|
|
388
|
+
/** The scoreboard of a contest: the osaekomi timer's label, the shido row's, a mat (`{n}`), a contest (`{n}`), the coach (`{name}`), and the drawing's default title. */
|
|
389
|
+
judoOsaekomi: string;
|
|
390
|
+
judoShido: string;
|
|
391
|
+
judoMat: string;
|
|
392
|
+
judoContest: string;
|
|
393
|
+
judoCoach: string;
|
|
394
|
+
judoBoardTitle: string;
|
|
388
395
|
}
|
|
389
396
|
/** A language: its words, the names of the rounds (by their English names), and the tag `Intl` formats numbers and dates for. */
|
|
390
397
|
export interface Locale {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.8.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.8.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/taisen-brackets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|