@johnmorrisdotca/taisen-brackets 0.10.0 → 0.11.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 +16 -0
- package/README.md +12 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/locales.js +10 -0
- package/dist/rank-kit.d.ts +90 -0
- package/dist/rank-kit.js +124 -0
- package/dist/ranking.d.ts +95 -0
- package/dist/ranking.js +391 -0
- package/dist/types.d.ts +17 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ All notable changes to this project are written here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.0] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
Rankings, step 1: the ranking list.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`rankingList(source, options)`**: a ranking for any ranked sport as an SVG, from the caller's data (a province's or a club's feed, a federation's list). Text first, with no photo slot: the rank, a flag (a country's or a Canadian province's or territory's, `CA-BC`), the name, the club and the points, with the movement since the last list. `source` is `{ title?, category?, date?, source?, entries }` and an entry `{ name, rank?, points?, club?, country?, code?, flag?, mark?, category?, change?, champion? }`. Styles: `podium` (the top three as big rows tinted gold, silver and bronze above the compact rest; on a wide board the big rows stand in a panel beside the others), `featured` (one big row above two columns) and `list`; `big`, `columns`, `split`, `medals`. Movement as arrows with the number of places, as green and red row colours, both, or none; `NR` for the newly ranked; a dash for no change. Tied ranks (equal points share a rank: 9, 10, 10, 12), an own `rank`, `rankStyle: "hash"` (`#10`). A champion row marked `C` (`IC` for an interim champion) above rank 1. `precision` writes points to the sport's own decimals (`91.60`, `50.00`), `nameStyle` writes `SURNAME Given`, `Given SURNAME` or the name as it is, `sort`, `categories`, `badge` (a club's initials where there is no flag), `rows` and `page` (the header says which entries are shown) and `flagShape`. Where the row is narrow the type steps down and then the text is cut with an ellipsis; nothing is stretched.
|
|
16
|
+
- **`rankingRanks(entries, { ties })`**, **`rankingCategoryLabel({ age, sex, weight }, locale)`** (`U18 · Men -60 kg`) and **`describeRanking(source, options)`**, the drawing's description.
|
|
17
|
+
- **`BOARD_SIZES`** and the options every board takes (`BoardOptions`): `tv`, `tablet`, `tablet-tall`, `phone` (600 by 1300), `square` and `portrait` (4:5) for a social card, or your own `{ width, height }`; light, dark or `auto`; five languages; custom properties `--rk-*`. The medal table and the results boards that follow share them.
|
|
18
|
+
- Words for ranking lists in the five languages (the French and Japanese were read by native-language reviewers; the Spanish and German were not). The Japanese marks the champion with `王者` and the interim champion with `暫定`.
|
|
19
|
+
- The demo has a Ranking lists section with a made-up provincial feed (U18 men -60 kg, and a senior women's list with a champion): style, size, look, movement, columns, name and rank style and paging, with the call that draws it; the state is in the address. [docs/rankings.md](docs/rankings.md).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The demo's flags come from Hata 1.6.2, which has all thirteen Canadian provinces and territories.
|
|
24
|
+
|
|
9
25
|
## [0.10.0] - 2026-10-20
|
|
10
26
|
|
|
11
27
|
Judo, step 4: the road to the final of one judoka.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Give it the games, series, two-legged ties or replays of a tournament and it wor
|
|
|
11
11
|
<img alt="TypeScript" src="https://img.shields.io/badge/types-TypeScript-3178c6">
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
<p align="center"><a href="https://johnmorrisdotca.github.io/taisen-brackets/"><strong>Open the demo →</strong></a> · <a href="./docs/layout.md">Layout</a> · <a href="./docs/layouts.md">Layouts</a> · <a href="./docs/drawing.md">Drawing</a> · <a href="./docs/road.md">Road to the final</a> · <a href="./docs/backgrounds.md">Backgrounds</a> · <a href="./docs/judo.md">Judo</a> · <a href="./docs/formats.md">Formats</a> · <a href="./docs/compare.md">How it compares</a></p>
|
|
14
|
+
<p align="center"><a href="https://johnmorrisdotca.github.io/taisen-brackets/"><strong>Open the demo →</strong></a> · <a href="./docs/layout.md">Layout</a> · <a href="./docs/layouts.md">Layouts</a> · <a href="./docs/drawing.md">Drawing</a> · <a href="./docs/road.md">Road to the final</a> · <a href="./docs/backgrounds.md">Backgrounds</a> · <a href="./docs/judo.md">Judo</a> · <a href="./docs/rankings.md">Rankings</a> · <a href="./docs/formats.md">Formats</a> · <a href="./docs/compare.md">How it compares</a></p>
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<picture>
|
|
@@ -247,6 +247,15 @@ A judo contest is a tie with a `judo` result: ippon, waza-ari, yuko and shido as
|
|
|
247
247
|
<img alt="A made-up men's minus 66 kilogram judo tournament of 16 athletes with round flags, score marks, shido cards, how each contest was won and its time, and the repechage and bronze medal contests under the tree." src="https://raw.githubusercontent.com/johnmorrisdotca/taisen-brackets/main/docs/images/judo-desk-light.webp" width="720">
|
|
248
248
|
</picture><br><em>Men's -66 kg, with the quarter-final repechage (invented names).</em></p>
|
|
249
249
|
|
|
250
|
+
## Rankings
|
|
251
|
+
|
|
252
|
+
`rankingList` draws a ranking for any ranked sport from your own data (a province's or a club's feed, a federation's list), text first, with no photo slot to leave empty: the rank, a flag (a country's or a Canadian province's, `CA-BC`), the name, the club and the points, with the movement since the last list as arrows or row colours. The top three can be big gold, silver and bronze rows above a compact list, a champion row is marked `C`, tied ranks share a number (`10`, `10`), a newly ranked athlete is `NR`, and points are written to the sport's own precision (`91.60`). `rankingRanks` and `rankingCategoryLabel` are its helpers, `describeRanking` its description, and `BOARD_SIZES` the sizes of every board (a TV, a tablet, a phone, and a square and a 4:5 card for social media). [docs/rankings.md](./docs/rankings.md) · [try it in the demo](https://johnmorrisdotca.github.io/taisen-brackets/#rankings)
|
|
253
|
+
|
|
254
|
+
<p align="center"><picture>
|
|
255
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/taisen-brackets/main/docs/images/ranking-podium-tv-dark.webp">
|
|
256
|
+
<img alt="A ranking list on a TV: three big rows tinted gold, silver and bronze for the first three, and the ranks 4 to 15 as compact rows beside them, each with its rank, movement arrow, round flag, name, club and points." src="https://raw.githubusercontent.com/johnmorrisdotca/taisen-brackets/main/docs/images/ranking-podium-tv-light.webp" width="720">
|
|
257
|
+
</picture><br><em>A made-up provincial ranking (invented names and clubs).</em></p>
|
|
258
|
+
|
|
250
259
|
## In 30 seconds
|
|
251
260
|
|
|
252
261
|
```sh
|
|
@@ -295,6 +304,7 @@ Put the string in a page (`element.innerHTML = svg`), write it to a file, or sen
|
|
|
295
304
|
- **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.
|
|
296
305
|
- **The road to the final of a judoka**: `judoWins` reads an athlete's wins from a bracket (a top seed's bye included) and `judoRoadPanel` draws them with the opponent's flag, code and name and how each was won.
|
|
297
306
|
- **Judo screens for the hall**: `judoContestCard` (the category, the round, the white athlete over the blue one) and `judoMatQueue` (per mat, a big number and colour and the contests to come in order, with flags and codes), on a TV or a tablet, in five languages.
|
|
307
|
+
- **Ranking lists**: `rankingList` draws a ranking from your data, text first: rank, flag (a country or a Canadian province), name, club, points, movement arrows, a podium, a champion row, ties and paging ([docs/rankings.md](./docs/rankings.md)).
|
|
298
308
|
- **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)).
|
|
299
309
|
- **Formats by era**: NHL, NBA, MLB, NFL, CFL, MLS and FIFA World Cup round names, series lengths and byes.
|
|
300
310
|
- **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.
|
|
@@ -656,6 +666,7 @@ Everything is exported from `@johnmorrisdotca/taisen-brackets`; the layout part
|
|
|
656
666
|
- `matchupsFromGames(games, options)`: matchups from a flat list of games.
|
|
657
667
|
- `resolveMatchup`, `collectTeams`, `describeTie`, `noteParts`, `noteLong`: one tie's result, the teams of a bracket, a tie in a sentence.
|
|
658
668
|
- `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. `judoContestCard(contest, options?)`, `judoMatQueue(mats, options?)`, `describeJudoCard`, `describeJudoQueue`, `JUDO_MAT_COLORS`: the contest card and the mat queue board as SVG and as sentences. `judoWins(bracket, athlete, options?)`, `judoRoadPanel(source, options?)`, `describeJudoRoad`: an athlete's wins on the road to the final, and the panel. `Matchup.judo` and `Matchup.stage` (`"repechage"`, `"bronze"`) carry them into `drawBracket`.
|
|
669
|
+
- `rankingList(source, options?)`, `rankingRanks(entries, options?)`, `rankingCategoryLabel(category, locale?)`, `describeRanking(source, options?)`, `BOARD_SIZES`: a ranking list as SVG, its ranks as data, a category as a heading and the drawing as a sentence ([docs/rankings.md](./docs/rankings.md)).
|
|
659
670
|
- `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.
|
|
660
671
|
- `isEmblemId(value)`, and the types `EmblemRenderer`, `EmblemRequest`, `EmblemStyle`: the trophy emblems (see "Emblems for the trophy").
|
|
661
672
|
- `roundName(remaining)`, `roundNamesFor(count)`, `roundLabel(layout, round, names, words)`: round names.
|
package/dist/index.d.ts
CHANGED
|
@@ -25,3 +25,7 @@ export { judoContestCard, describeJudoCard, judoMatQueue, describeJudoQueue, JUD
|
|
|
25
25
|
export type { JudoCardContest, JudoMat, JudoQueueContest, JudoQueueOptions } from "./judo-sheets.ts";
|
|
26
26
|
export { judoWins, judoRoadPanel, describeJudoRoad } from "./judo-road.ts";
|
|
27
27
|
export type { JudoRoadOptions, JudoRoadSource, JudoWin, JudoWinsOptions } from "./judo-road.ts";
|
|
28
|
+
export { rankingList, rankingRanks, rankingCategoryLabel, describeRanking } from "./ranking.ts";
|
|
29
|
+
export type { RankingEntry, RankingName, RankingOptions, RankingSource } from "./ranking.ts";
|
|
30
|
+
export { BOARD_SIZES } from "./rank-kit.ts";
|
|
31
|
+
export type { BoardOptions, BoardSizeName } from "./rank-kit.ts";
|
package/dist/index.js
CHANGED
|
@@ -14,3 +14,5 @@ export { judoBracket, judoPodium, judoCategoryLabel, judoOutcome, judoSeconds, j
|
|
|
14
14
|
export { judoScoreboard, describeJudoContest, JUDO_BOARD_SIZES } from "./judo-board.js";
|
|
15
15
|
export { judoContestCard, describeJudoCard, judoMatQueue, describeJudoQueue, JUDO_MAT_COLORS } from "./judo-sheets.js";
|
|
16
16
|
export { judoWins, judoRoadPanel, describeJudoRoad } from "./judo-road.js";
|
|
17
|
+
export { rankingList, rankingRanks, rankingCategoryLabel, describeRanking } from "./ranking.js";
|
|
18
|
+
export { BOARD_SIZES } from "./rank-kit.js";
|
package/dist/locales.js
CHANGED
|
@@ -28,6 +28,8 @@ const EN = {
|
|
|
28
28
|
judoWeightClass: "{sex} -{w} kg", judoWeightOver: "{sex} +{w} kg", judoMen: "Men", judoWomen: "Women",
|
|
29
29
|
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Mat {n}", judoContest: "Contest {n}", judoCoach: "Coach: {name}", judoBoardTitle: "Judo contest scoreboard",
|
|
30
30
|
judoCardTitle: "Judo contest card", judoQueueTitle: "Judo mat queue",
|
|
31
|
+
rankTitle: "Ranking", rankPoints: "points", rankPointsShort: "pts", rankChampion: "C", rankChampionLong: "Champion", rankInterim: "IC", rankInterimLong: "Interim champion",
|
|
32
|
+
rankNew: "NR", rankNewLong: "newly ranked", rankUp: "up {n}", rankDown: "down {n}", rankSame: "unchanged", rankRange: "{a}–{b} of {n}", rankNone: "No ranked athletes yet", rankJoin: ", ",
|
|
31
33
|
};
|
|
32
34
|
const FR = {
|
|
33
35
|
tbd: "À déterminer", bye: "exempt", walkover: "forfait", champion: "Champion", thirdPlace: "Match pour la troisième place", other: "Autres rencontres",
|
|
@@ -52,6 +54,8 @@ const FR = {
|
|
|
52
54
|
judoWeightClass: "{sex} -{w}\u00a0kg", judoWeightOver: "{sex} +{w}\u00a0kg", judoMen: "Hommes", judoWomen: "Femmes",
|
|
53
55
|
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Tapis {n}", judoContest: "Combat {n}", judoCoach: "Entraîneur\u00a0: {name}", judoBoardTitle: "Tableau d’affichage d’un combat de judo",
|
|
54
56
|
judoCardTitle: "Présentation d’un combat de judo", judoQueueTitle: "Prochains combats par tapis",
|
|
57
|
+
rankTitle: "Classement", rankPoints: "points", rankPointsShort: "pts", rankChampion: "C", rankChampionLong: "Champion", rankInterim: "CI", rankInterimLong: "Champion intérimaire",
|
|
58
|
+
rankNew: "Nouv.", rankNewLong: "nouvelle entrée au classement", rankUp: "en hausse de {n}", rankDown: "en baisse de {n}", rankSame: "rang inchangé", rankRange: "{a}–{b} sur {n}", rankNone: "Aucun athlète classé pour l’instant", rankJoin: ", ",
|
|
55
59
|
};
|
|
56
60
|
const JA = {
|
|
57
61
|
tbd: "未定", bye: "シード", walkover: "不戦勝", champion: "優勝", thirdPlace: "3位決定戦", other: "その他の対戦",
|
|
@@ -76,6 +80,8 @@ const JA = {
|
|
|
76
80
|
judoWeightClass: "{sex}{w}kg級", judoWeightOver: "{sex}{w}kg超級", judoMen: "男子", judoWomen: "女子",
|
|
77
81
|
judoOsaekomi: "抑え込み", judoShido: "指導", judoMat: "第{n}試合場", judoContest: "試合 {n}", judoCoach: "コーチ:{name}", judoBoardTitle: "柔道試合のスコアボード",
|
|
78
82
|
judoCardTitle: "柔道の対戦カード", judoQueueTitle: "各試合場の次の試合",
|
|
83
|
+
rankTitle: "ランキング", rankPoints: "ポイント", rankPointsShort: "pt", rankChampion: "王者", rankChampionLong: "王者", rankInterim: "暫定", rankInterimLong: "暫定王者",
|
|
84
|
+
rankNew: "NEW", rankNewLong: "新規ランクイン", rankUp: "{n}ランク上昇", rankDown: "{n}ランク下降", rankSame: "変動なし", rankRange: "{n}人中{a}–{b}位", rankNone: "ランキング入りした選手はまだいません", rankJoin: "、",
|
|
79
85
|
};
|
|
80
86
|
const ES = {
|
|
81
87
|
tbd: "Por definir", bye: "exento", walkover: "incomparecencia", champion: "Campeón", thirdPlace: "Partido por el tercer puesto", other: "Otros partidos",
|
|
@@ -100,6 +106,8 @@ const ES = {
|
|
|
100
106
|
judoWeightClass: "{sex} -{w} kg", judoWeightOver: "{sex} +{w} kg", judoMen: "Hombres", judoWomen: "Mujeres",
|
|
101
107
|
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Tatami {n}", judoContest: "Combate {n}", judoCoach: "Entrenador: {name}", judoBoardTitle: "Marcador de un combate de judo",
|
|
102
108
|
judoCardTitle: "Ficha de un combate de judo", judoQueueTitle: "Próximos combates por tatami",
|
|
109
|
+
rankTitle: "Clasificación", rankPoints: "puntos", rankPointsShort: "pts", rankChampion: "C", rankChampionLong: "Campeón", rankInterim: "CI", rankInterimLong: "Campeón interino",
|
|
110
|
+
rankNew: "NUEVO", rankNewLong: "nuevo en la clasificación", rankUp: "sube {n}", rankDown: "baja {n}", rankSame: "sin cambios", rankRange: "{a}–{b} de {n}", rankNone: "Aún no hay deportistas clasificados", rankJoin: ", ",
|
|
103
111
|
};
|
|
104
112
|
const DE = {
|
|
105
113
|
tbd: "offen", bye: "Freilos", walkover: "kampflos", champion: "Turniersieger", thirdPlace: "Spiel um Platz 3", other: "Weitere Begegnungen",
|
|
@@ -124,6 +132,8 @@ const DE = {
|
|
|
124
132
|
judoWeightClass: "{sex} -{w} kg", judoWeightOver: "{sex} +{w} kg", judoMen: "Herren", judoWomen: "Damen",
|
|
125
133
|
judoOsaekomi: "Osaekomi", judoShido: "Shido", judoMat: "Matte {n}", judoContest: "Kampf {n}", judoCoach: "Trainer: {name}", judoBoardTitle: "Anzeigetafel eines Judokampfes",
|
|
126
134
|
judoCardTitle: "Kampfkarte eines Judokampfes", judoQueueTitle: "Nächste Kämpfe je Matte",
|
|
135
|
+
rankTitle: "Rangliste", rankPoints: "Punkte", rankPointsShort: "Pkt.", rankChampion: "C", rankChampionLong: "Champion", rankInterim: "IC", rankInterimLong: "Interimschampion",
|
|
136
|
+
rankNew: "NEU", rankNewLong: "neu in der Rangliste", rankUp: "{n} Plätze höher", rankDown: "{n} Plätze tiefer", rankSame: "unverändert", rankRange: "{a}–{b} von {n}", rankNone: "Noch keine platzierten Athleten", rankJoin: ", ",
|
|
127
137
|
};
|
|
128
138
|
/**
|
|
129
139
|
* The round names, English name first. The football rounds are the terms of each language's Wikipedia article on the World Cup;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the ranking lists, the medal table and the results boards share: the sizes, the frame (size, theme, language, flags), the stylesheet with its light and dark palettes, the
|
|
3
|
+
* wrapper that makes the SVG, and the small shapes (a movement arrow, a pill, a rank badge). Every board is a static SVG for a given state, a pure function of its input.
|
|
4
|
+
*/
|
|
5
|
+
import type { DrawOptions, Locale, Words } from "./types.ts";
|
|
6
|
+
import { bw, clampN, fitSize } from "./judo-board.ts";
|
|
7
|
+
import { type Voice } from "./locales.ts";
|
|
8
|
+
import { type MarkContext } from "./marks.ts";
|
|
9
|
+
export { bw, clampN, fitSize };
|
|
10
|
+
export type BoardSizeName = "tv" | "tablet" | "tablet-tall" | "phone" | "square" | "portrait";
|
|
11
|
+
/** The sizes of a board: a TV's 1920 by 1080, a tablet on its side (1280 by 800) and upright (800 by 1280), a phone's 600 by 1300, and the social-card shapes, square (1080) and 4:5 (1080 by 1350). */
|
|
12
|
+
export declare const BOARD_SIZES: Readonly<Record<BoardSizeName, {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}>>;
|
|
16
|
+
/** What every board takes. */
|
|
17
|
+
export interface BoardOptions {
|
|
18
|
+
/** One of `BOARD_SIZES` (`tv` by default), or a size of your own, `{ width, height }`, at least 200 each way. */
|
|
19
|
+
size?: BoardSizeName | {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
};
|
|
23
|
+
/** `auto` (the default) follows the page's light or dark, `light` and `dark` fix it. */
|
|
24
|
+
theme?: "auto" | "light" | "dark";
|
|
25
|
+
/** Draws a flag from its code, as for `drawBracket`: a synchronous function from a code (`CA-BC`, `JP`) and `{ shape, size, theme }` to an SVG string, such as one made from `@johnmorrisdotca/hata`. */
|
|
26
|
+
flags?: DrawOptions["flags"];
|
|
27
|
+
/** The address of a flag as a picture to load, tried where `flags` gives none. */
|
|
28
|
+
flagHref?: DrawOptions["flagHref"];
|
|
29
|
+
/** `round` (the default) or `square`: the shape a flag is asked for and drawn in. */
|
|
30
|
+
flagShape?: "round" | "square";
|
|
31
|
+
/** The language: `en`, `fr`, `ja`, `es`, `de` or a `Locale`. */
|
|
32
|
+
locale?: string | Locale;
|
|
33
|
+
words?: Partial<Words>;
|
|
34
|
+
/** The `<title>` of the drawing. Default: the board's own heading. */
|
|
35
|
+
title?: string;
|
|
36
|
+
/** Prefix for the drawing's ids. Default: a hash of the state and the options. */
|
|
37
|
+
id?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface BoardFrame {
|
|
41
|
+
W: number;
|
|
42
|
+
H: number;
|
|
43
|
+
v: Voice;
|
|
44
|
+
scope: string;
|
|
45
|
+
theme: "auto" | "light" | "dark";
|
|
46
|
+
ctx: MarkContext;
|
|
47
|
+
options: BoardOptions;
|
|
48
|
+
}
|
|
49
|
+
export declare const rect: (x: number, y: number, w: number, h: number, cls: string, rx?: number, extra?: string) => string;
|
|
50
|
+
export declare const label: (x: number, y: number, size: number, content: string, cls: string, anchor?: "start" | "middle" | "end", weight?: number, extra?: string) => string;
|
|
51
|
+
/** The size, theme and language a board is drawn with, checked; `seed` is whatever the drawing depends on, for the hash of the ids. */
|
|
52
|
+
export declare function boardFrame(options: BoardOptions, seed: unknown): BoardFrame;
|
|
53
|
+
export interface BoardPalette {
|
|
54
|
+
bg: string;
|
|
55
|
+
ink: string;
|
|
56
|
+
sub: string;
|
|
57
|
+
accent: string;
|
|
58
|
+
accentInk: string;
|
|
59
|
+
row: string;
|
|
60
|
+
rowAlt: string;
|
|
61
|
+
edge: string;
|
|
62
|
+
head: string;
|
|
63
|
+
headInk: string;
|
|
64
|
+
up: string;
|
|
65
|
+
down: string;
|
|
66
|
+
upRow: string;
|
|
67
|
+
downRow: string;
|
|
68
|
+
pill: string;
|
|
69
|
+
pillInk: string;
|
|
70
|
+
/** Gold, silver, bronze: the two ends of the row's gradient, the edge and the numeral. */
|
|
71
|
+
gold: readonly [string, string, string, string];
|
|
72
|
+
silver: readonly [string, string, string, string];
|
|
73
|
+
bronze: readonly [string, string, string, string];
|
|
74
|
+
}
|
|
75
|
+
export declare const BOARD_LIGHT: BoardPalette;
|
|
76
|
+
export declare const BOARD_DARK: BoardPalette;
|
|
77
|
+
/** The rules for one palette, scoped, with `before` ahead of every selector (empty, or the dark-mode root selector). */
|
|
78
|
+
export declare function boardRules(p: BoardPalette, scope: string, before: string, extra?: (sel: (s: string, body: string) => string, v: (name: string, value: string) => string) => string[]): string;
|
|
79
|
+
/** The whole stylesheet: the light palette, the dark one under the page's dark mode (for `auto`) or on its own, and the fixed rules. */
|
|
80
|
+
export declare function boardStyle(scope: string, theme: "auto" | "light" | "dark", extra?: Parameters<typeof boardRules>[3], fixed?: string): string;
|
|
81
|
+
/** The medal gradients a board's rows use: one for gold, silver and bronze, by id. */
|
|
82
|
+
export declare function medalDefs(scope: string): string;
|
|
83
|
+
export declare function boardWrap(f: BoardFrame, title: string, desc: string, body: string[], kind: string, extra?: Parameters<typeof boardRules>[3], fixed?: string, defs?: string): string;
|
|
84
|
+
/** A movement arrow, a triangle `s` high centred at (cx, cy), up or down. */
|
|
85
|
+
export declare function arrow(cx: number, cy: number, s: number, up: boolean): string;
|
|
86
|
+
/** A pill with centred text in it, as wide as the text needs (never wider than `maxW`). Returns the pieces and the width. */
|
|
87
|
+
export declare function pill(x: number, cy: number, h: number, content: string, maxW: number, cls?: string, inkCls?: string, anchor?: "start" | "end"): {
|
|
88
|
+
svg: string;
|
|
89
|
+
w: number;
|
|
90
|
+
};
|
package/dist/rank-kit.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { bw, clampN, fitSize, FONT } from "./judo-board.js";
|
|
2
|
+
import { voiceOf } from "./locales.js";
|
|
3
|
+
import { markDefs } from "./marks.js";
|
|
4
|
+
import { esc, fit, hash, num } from "./text.js";
|
|
5
|
+
export { bw, clampN, fitSize };
|
|
6
|
+
/** The sizes of a board: a TV's 1920 by 1080, a tablet on its side (1280 by 800) and upright (800 by 1280), a phone's 600 by 1300, and the social-card shapes, square (1080) and 4:5 (1080 by 1350). */
|
|
7
|
+
export const BOARD_SIZES = Object.freeze({
|
|
8
|
+
tv: { width: 1920, height: 1080 },
|
|
9
|
+
tablet: { width: 1280, height: 800 },
|
|
10
|
+
"tablet-tall": { width: 800, height: 1280 },
|
|
11
|
+
phone: { width: 600, height: 1300 },
|
|
12
|
+
square: { width: 1080, height: 1080 },
|
|
13
|
+
portrait: { width: 1080, height: 1350 },
|
|
14
|
+
});
|
|
15
|
+
export const rect = (x, y, w, h, cls, rx = 0, extra = "") => `<rect class="${cls}" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}"${rx ? ` rx="${num(rx)}"` : ""}${extra}/>`;
|
|
16
|
+
export const label = (x, y, size, content, 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(content)}</text>`;
|
|
17
|
+
/** An id for the drawing's classes and ids, made safe: letters, digits, `_` and `-` only (anything else becomes `-`), and a letter first. `undefined` stays `undefined`. */
|
|
18
|
+
const safeId = (id) => (id === undefined ? undefined : /^[A-Za-z]/.test(id) ? id.replace(/[^\w-]/g, "-") : `rk-${id.replace(/[^\w-]/g, "-")}`);
|
|
19
|
+
/** The size, theme and language a board is drawn with, checked; `seed` is whatever the drawing depends on, for the hash of the ids. */
|
|
20
|
+
export function boardFrame(options, seed) {
|
|
21
|
+
const v = voiceOf(options);
|
|
22
|
+
const size = typeof options.size === "string" ? BOARD_SIZES[options.size] : options.size ?? BOARD_SIZES.tv;
|
|
23
|
+
if (!size || !(size.width >= 200) || !(size.height >= 200))
|
|
24
|
+
throw new RangeError(`taisen-brackets: a size is ${Object.keys(BOARD_SIZES).map((k) => `"${k}"`).join(", ")} or { width, height } of 200 or more, not ${JSON.stringify(options.size)}`);
|
|
25
|
+
const theme = options.theme ?? "auto";
|
|
26
|
+
if (theme !== "auto" && theme !== "light" && theme !== "dark")
|
|
27
|
+
throw new RangeError(`taisen-brackets: a theme is "auto", "light" or "dark", not ${JSON.stringify(theme)}`);
|
|
28
|
+
const shape = options.flagShape ?? "round";
|
|
29
|
+
if (shape !== "round" && shape !== "square")
|
|
30
|
+
throw new RangeError(`taisen-brackets: a flag shape is "round" or "square", not ${JSON.stringify(options.flagShape)}`);
|
|
31
|
+
const scope = safeId(options.id) ?? `rk-${hash(JSON.stringify([seed, options.size, theme, shape, v.locale.code, options.flags ? 1 : 0, options.flagHref ? 1 : 0, options.title]))}`;
|
|
32
|
+
const ctx = { radii: new Set(), symbols: new Map(), prefix: scope, shape, theme: "inherit", flagCache: new Map(), ...(options.flags ? { flags: options.flags } : {}), ...(options.flagHref ? { flagHref: options.flagHref } : {}) };
|
|
33
|
+
return { W: size.width, H: size.height, v, scope, theme, ctx, options };
|
|
34
|
+
}
|
|
35
|
+
export const BOARD_LIGHT = {
|
|
36
|
+
bg: "#eceff4", ink: "#131822", sub: "#566071", accent: "#2457c5", accentInk: "#ffffff",
|
|
37
|
+
row: "#ffffff", rowAlt: "#f6f8fb", edge: "#d3d9e3", head: "#16233f", headInk: "#ffffff",
|
|
38
|
+
up: "#0f8a46", down: "#c3302a", upRow: "#e0f4e8", downRow: "#fbe3e1",
|
|
39
|
+
pill: "#e3e8f1", pillInk: "#2a3345",
|
|
40
|
+
gold: ["#f8e9b5", "#fdf6dc", "#d4a929", "#7d5d00"], silver: ["#e4e8ee", "#f3f5f8", "#a9b2c0", "#4f586a"], bronze: ["#f2d5ba", "#faeadb", "#c98650", "#82451a"],
|
|
41
|
+
};
|
|
42
|
+
export const BOARD_DARK = {
|
|
43
|
+
bg: "#0b0f17", ink: "#f0f3f9", sub: "#98a3b8", accent: "#6ea2ff", accentInk: "#0b0f17",
|
|
44
|
+
row: "#161d2b", rowAlt: "#121826", edge: "#27324a", head: "#1b2742", headInk: "#f0f3f9",
|
|
45
|
+
up: "#4fdc8b", down: "#ff7b72", upRow: "#123724", downRow: "#451d1d",
|
|
46
|
+
pill: "#25304a", pillInk: "#dfe6f5",
|
|
47
|
+
gold: ["#5a4813", "#2c2512", "#d0aa3a", "#f5d263"], silver: ["#434c60", "#232a38", "#9ba6bb", "#e3e8f2"], bronze: ["#58341b", "#2b1d14", "#c4854f", "#f3a96f"],
|
|
48
|
+
};
|
|
49
|
+
const MEDALS = ["gold", "silver", "bronze"];
|
|
50
|
+
/** The rules for one palette, scoped, with `before` ahead of every selector (empty, or the dark-mode root selector). */
|
|
51
|
+
export function boardRules(p, scope, before, extra) {
|
|
52
|
+
const sel = (s, body) => `${before}.${scope} ${s}{${body}}`;
|
|
53
|
+
const v = (name, value) => `var(--rk-${name}, ${value})`;
|
|
54
|
+
const out = [
|
|
55
|
+
sel(".rk-bg", `fill:${v("bg", p.bg)}`),
|
|
56
|
+
sel(".rk-ink", `fill:${v("ink", p.ink)}`),
|
|
57
|
+
sel(".rk-sub", `fill:${v("sub", p.sub)}`),
|
|
58
|
+
sel(".rk-accent", `fill:${v("accent", p.accent)}`),
|
|
59
|
+
sel(".rk-accentink", `fill:${v("accent-ink", p.accentInk)}`),
|
|
60
|
+
sel(".rk-head", `fill:${v("head", p.head)}`),
|
|
61
|
+
sel(".rk-headink", `fill:${v("head-ink", p.headInk)}`),
|
|
62
|
+
sel(".rk-row", `fill:${v("row", p.row)};stroke:${v("edge", p.edge)};stroke-width:1.5`),
|
|
63
|
+
sel(".rk-row.rk-alt", `fill:${v("row-alt", p.rowAlt)}`),
|
|
64
|
+
sel(".rk-sep", `stroke:${v("edge", p.edge)};stroke-width:1.5`),
|
|
65
|
+
sel(".rk-up", `fill:${v("up", p.up)}`),
|
|
66
|
+
sel(".rk-down", `fill:${v("down", p.down)}`),
|
|
67
|
+
sel(".rk-row.rk-mup", `fill:${v("up-row", p.upRow)}`),
|
|
68
|
+
sel(".rk-row.rk-mdown", `fill:${v("down-row", p.downRow)}`),
|
|
69
|
+
sel(".rk-pill", `fill:${v("pill", p.pill)}`),
|
|
70
|
+
sel(".rk-pillink", `fill:${v("pill-ink", p.pillInk)}`),
|
|
71
|
+
sel(".rk-nr", `fill:${v("accent", p.accent)}`),
|
|
72
|
+
sel(".rk-nrink", `fill:${v("accent-ink", p.accentInk)}`),
|
|
73
|
+
sel(".tb-ring", `fill:none;stroke:rgba(0,0,0,.28);stroke-width:1.5`),
|
|
74
|
+
sel(".tb-plain", `fill:${v("pill", p.pill)}`),
|
|
75
|
+
sel(".tb-initials", `fill:${v("pill-ink", p.pillInk)}`),
|
|
76
|
+
];
|
|
77
|
+
MEDALS.forEach((m, i) => {
|
|
78
|
+
const c = p[m];
|
|
79
|
+
out.push(sel(`.rk-ga${i + 1}`, `stop-color:${c[0]}`), sel(`.rk-gb${i + 1}`, `stop-color:${c[1]}`), sel(`.rk-medal${i + 1}`, `stroke:${c[2]};stroke-width:2`), sel(`.rk-num${i + 1}`, `fill:${c[3]}`), sel(`.rk-bar${i + 1}`, `fill:${c[2]}`));
|
|
80
|
+
});
|
|
81
|
+
if (extra)
|
|
82
|
+
out.push(...extra(sel, v));
|
|
83
|
+
return out.join("\n");
|
|
84
|
+
}
|
|
85
|
+
/** The whole stylesheet: the light palette, the dark one under the page's dark mode (for `auto`) or on its own, and the fixed rules. */
|
|
86
|
+
export function boardStyle(scope, theme, extra, fixed = "") {
|
|
87
|
+
const tail = [`.${scope} text{font-family:var(--rk-font, ${FONT});font-variant-numeric:tabular-nums}`, fixed.replaceAll("$S", `.${scope}`)].filter(Boolean).join("\n");
|
|
88
|
+
if (theme === "light")
|
|
89
|
+
return `${boardRules(BOARD_LIGHT, scope, "", extra)}\n${tail}`;
|
|
90
|
+
if (theme === "dark")
|
|
91
|
+
return `${boardRules(BOARD_DARK, scope, "", extra)}\n${tail}`;
|
|
92
|
+
return `${boardRules(BOARD_LIGHT, scope, "", extra)}\n@media (prefers-color-scheme: dark){\n${boardRules(BOARD_DARK, scope, ':root:not([data-theme="light"]) ', extra)}\n}\n${boardRules(BOARD_DARK, scope, ':root[data-theme="dark"] ', extra)}\n${tail}`;
|
|
93
|
+
}
|
|
94
|
+
/** The medal gradients a board's rows use: one for gold, silver and bronze, by id. */
|
|
95
|
+
export function medalDefs(scope) {
|
|
96
|
+
return MEDALS.map((_, i) => `<linearGradient id="${esc(scope)}-g${i + 1}" x1="0" x2="1" y1="0" y2="0"><stop class="rk-ga${i + 1}" offset="0"/><stop class="rk-gb${i + 1}" offset="1"/></linearGradient>`).join("");
|
|
97
|
+
}
|
|
98
|
+
export function boardWrap(f, title, desc, body, kind, extra, fixed = "", defs = "") {
|
|
99
|
+
const marks = markDefs(f.ctx);
|
|
100
|
+
return [
|
|
101
|
+
`<svg xmlns="http://www.w3.org/2000/svg" lang="${esc(f.v.locale.code)}" class="taisen taisen-board ${esc(f.scope)}${f.options.className ? ` ${esc(f.options.className)}` : ""}" viewBox="0 0 ${num(f.W)} ${num(f.H)}" width="${num(f.W)}" height="${num(f.H)}" style="max-width:100%;height:auto" role="img" aria-labelledby="${esc(f.scope)}-t ${esc(f.scope)}-d" data-board="${kind}">`,
|
|
102
|
+
`<title id="${esc(f.scope)}-t">${esc(title)}</title><desc id="${esc(f.scope)}-d">${esc(desc)}</desc>`,
|
|
103
|
+
`<style>${boardStyle(f.scope, f.theme, extra, fixed)}</style>`,
|
|
104
|
+
`<defs>${medalDefs(f.scope)}${defs}${marks}</defs>`,
|
|
105
|
+
`<rect class="rk-bg" width="${num(f.W)}" height="${num(f.H)}"/>`,
|
|
106
|
+
...body,
|
|
107
|
+
"</svg>",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
// ------------------------------------------------------------------------------------------------ small shapes
|
|
111
|
+
/** A movement arrow, a triangle `s` high centred at (cx, cy), up or down. */
|
|
112
|
+
export function arrow(cx, cy, s, up) {
|
|
113
|
+
const w = s * 1.1, h = s * 0.9;
|
|
114
|
+
const pts = up ? [[cx, cy - h / 2], [cx + w / 2, cy + h / 2], [cx - w / 2, cy + h / 2]] : [[cx - w / 2, cy - h / 2], [cx + w / 2, cy - h / 2], [cx, cy + h / 2]];
|
|
115
|
+
return `<polygon class="${up ? "rk-up" : "rk-down"}" points="${pts.map(([x, y]) => `${num(x)},${num(y)}`).join(" ")}"/>`;
|
|
116
|
+
}
|
|
117
|
+
/** A pill with centred text in it, as wide as the text needs (never wider than `maxW`). Returns the pieces and the width. */
|
|
118
|
+
export function pill(x, cy, h, content, maxW, cls = "rk-pill", inkCls = "rk-pillink", anchor = "start") {
|
|
119
|
+
const size = h * 0.56;
|
|
120
|
+
const fs = fitSize(content, size, maxW - h * 0.7, 0.6);
|
|
121
|
+
const w = Math.min(maxW, bw(content, fs) + h * 0.7);
|
|
122
|
+
const x0 = anchor === "start" ? x : x - w;
|
|
123
|
+
return { svg: `${rect(x0, cy - h / 2, w, h, cls, h / 2)}${label(x0 + w / 2, cy + fs * 0.36, fs, fit(content, fs, w, bw), inkCls, "middle", 800, ' letter-spacing=".03em"')}`, w };
|
|
124
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking lists for any ranked sport, drawn from the caller's data (a provincial or club feed, a federation's list): a title and a category, then the entries as a podium (the top
|
|
3
|
+
* three in gold, silver and bronze rows) above a compact list, as one featured row above two columns, or as a plain list. Text first: rank, flag, name, club, points and the
|
|
4
|
+
* movement since the last list, with no photo slot to leave empty. A champion row ("C"), an interim champion, newly ranked athletes ("NR"), tied ranks and paging are in.
|
|
5
|
+
* Static SVG for a given state, like every drawing here.
|
|
6
|
+
*/
|
|
7
|
+
import { type BoardOptions } from "./rank-kit.ts";
|
|
8
|
+
/** A name in two parts: the family name is written in capitals and first by default (`SURNAME Given`, as judo and the IJF write it). */
|
|
9
|
+
export interface RankingName {
|
|
10
|
+
given?: string;
|
|
11
|
+
surname: string;
|
|
12
|
+
}
|
|
13
|
+
/** One line of a ranking. Only `name` is needed. */
|
|
14
|
+
export interface RankingEntry {
|
|
15
|
+
/** The name as it is to be written, or its two parts (set by `nameStyle`). */
|
|
16
|
+
name: string | RankingName;
|
|
17
|
+
/** The rank. Left out, the position in the list, with equal points sharing a rank (`ties`). A champion has none. */
|
|
18
|
+
rank?: number;
|
|
19
|
+
/** A number (shown to `precision` decimals) or text (shown as it is). */
|
|
20
|
+
points?: number | string;
|
|
21
|
+
club?: string;
|
|
22
|
+
/** The country's or region's name, written under the name in a podium row. */
|
|
23
|
+
country?: string;
|
|
24
|
+
/** The country's three letters (`CAN`), written in the compact rows. */
|
|
25
|
+
code?: string;
|
|
26
|
+
/** A code for the `flags` renderer: a country (`JP`) or a Canadian province or territory (`CA-BC`). */
|
|
27
|
+
flag?: string;
|
|
28
|
+
/** A mark of your own (an image address or inline SVG) in place of a flag: a club's emblem. */
|
|
29
|
+
mark?: string;
|
|
30
|
+
/** This athlete's category ("U18 · -60 kg"), written in the second line when `categories` is on: for a list that mixes categories. */
|
|
31
|
+
category?: string;
|
|
32
|
+
/** The movement since the last list: whole places up (positive) or down (negative), `0` for none, `"new"` for newly ranked ("NR"). */
|
|
33
|
+
change?: number | "new";
|
|
34
|
+
/** The champion of the division: a row marked "C" above the ranks (`"interim"` for an interim champion). */
|
|
35
|
+
champion?: boolean | "interim";
|
|
36
|
+
}
|
|
37
|
+
export interface RankingSource {
|
|
38
|
+
/** The heading. Default: the language's word for ranking. */
|
|
39
|
+
title?: string;
|
|
40
|
+
/** The category as a line under the heading ("U18 · Men -60 kg"; `rankingCategoryLabel` writes one). */
|
|
41
|
+
category?: string;
|
|
42
|
+
/** The date or period of the list, at the right of the heading ("October 2026"). */
|
|
43
|
+
date?: string;
|
|
44
|
+
/** Where the list comes from, small at the foot ("Source: BC Judo"). */
|
|
45
|
+
source?: string;
|
|
46
|
+
entries: readonly RankingEntry[];
|
|
47
|
+
}
|
|
48
|
+
export interface RankingOptions extends BoardOptions {
|
|
49
|
+
/** `podium` (the default): the top three as big rows, tinted gold, silver and bronze, above the compact rest. `featured`: one big row above two columns. `list`: every row compact. */
|
|
50
|
+
style?: "podium" | "list" | "featured";
|
|
51
|
+
/** How many rows, from the top, are big (0 to 3). Default by `style`. Only on the first page. */
|
|
52
|
+
big?: number;
|
|
53
|
+
/** One column or two for the compact rows (the first column runs down before the second starts). Default 1, and 2 for `featured`. */
|
|
54
|
+
columns?: 1 | 2;
|
|
55
|
+
/** The most entries drawn, from the first of the page. Default: as many as fit at a readable size. */
|
|
56
|
+
rows?: number;
|
|
57
|
+
/** Which page of `rows` entries (0 is the first). The header says which entries are shown. */
|
|
58
|
+
page?: number;
|
|
59
|
+
/** On a wide board (at least 3:2) the big rows go in a panel on the left and the compact ones beside them, in place of one above the other. Default true; `false` stacks them. */
|
|
60
|
+
split?: boolean;
|
|
61
|
+
/** Tint the rows of ranks 1 to 3 gold, silver and bronze. Default: on for `podium` and `featured` (off when the list has a champion row), off for `list`. */
|
|
62
|
+
medals?: boolean;
|
|
63
|
+
/** How movement is shown: `arrow` (an arrow and the number of places, the default), `row` (the row tinted green or red), `both`, or `none`. */
|
|
64
|
+
movement?: "arrow" | "row" | "both" | "none";
|
|
65
|
+
/** `surname-first` (the default: `SURNAME Given`), `given-first` (`Given SURNAME`) or `as-is`, for a `name` given in two parts. */
|
|
66
|
+
nameStyle?: "surname-first" | "given-first" | "as-is";
|
|
67
|
+
/** `plain` (`10`, the default) or `hash` (`#10`). */
|
|
68
|
+
rankStyle?: "plain" | "hash";
|
|
69
|
+
/** Decimals for numeric points (`2` writes `91.60` and `50.00`). Default: as many as the number has, up to three. */
|
|
70
|
+
precision?: number;
|
|
71
|
+
/** Equal points share a rank where an entry names none (`1, 2, 2, 4`). Default true. */
|
|
72
|
+
ties?: boolean;
|
|
73
|
+
/** `given` (the default) keeps the order of the entries; `points` puts the most points first. */
|
|
74
|
+
sort?: "given" | "points";
|
|
75
|
+
/** Write each entry's own `category` in the second line. */
|
|
76
|
+
categories?: boolean;
|
|
77
|
+
/** Draw a club's (or the athlete's) initials on a badge for an entry with no flag or mark, even where no entry has one. Default: only where some entry has a flag or mark. */
|
|
78
|
+
badge?: boolean;
|
|
79
|
+
}
|
|
80
|
+
/** The ranks of a list, in order: an entry's own `rank`, else its position, with equal points sharing a rank; `null` for a champion row. Pure; takes the list as it is ordered. */
|
|
81
|
+
export declare function rankingRanks(entries: readonly RankingEntry[], options?: {
|
|
82
|
+
ties?: boolean;
|
|
83
|
+
}): (number | null)[];
|
|
84
|
+
/** A category as a heading ("U18 · Men -60 kg"): an age group, a sex and a weight, any of them, joined by a dot. A weight is `-60` or `+100` (kg) or any text. */
|
|
85
|
+
export declare function rankingCategoryLabel(category: {
|
|
86
|
+
age?: string;
|
|
87
|
+
sex?: "men" | "women" | "open";
|
|
88
|
+
weight?: string;
|
|
89
|
+
}, locale?: string): string;
|
|
90
|
+
/** The ranking as a sentence, the drawing's description. */
|
|
91
|
+
export declare function describeRanking(source: RankingSource, options?: Pick<RankingOptions, "locale" | "words" | "nameStyle" | "precision" | "ties" | "sort" | "rankStyle" | "rows" | "page">): string;
|
|
92
|
+
/**
|
|
93
|
+
* The ranking list as an SVG. Throws a `RangeError` for an entry with no name, a rank or change that is not a whole number, an option that is not one of its values, or a bad size or theme.
|
|
94
|
+
*/
|
|
95
|
+
export declare function rankingList(source: RankingSource, options?: RankingOptions): string;
|
package/dist/ranking.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking lists for any ranked sport, drawn from the caller's data (a provincial or club feed, a federation's list): a title and a category, then the entries as a podium (the top
|
|
3
|
+
* three in gold, silver and bronze rows) above a compact list, as one featured row above two columns, or as a plain list. Text first: rank, flag, name, club, points and the
|
|
4
|
+
* movement since the last list, with no photo slot to leave empty. A champion row ("C"), an interim champion, newly ranked athletes ("NR"), tied ranks and paging are in.
|
|
5
|
+
* Static SVG for a given state, like every drawing here.
|
|
6
|
+
*/
|
|
7
|
+
import { arrow, boardFrame, boardWrap, bw, clampN, fitSize, label, pill } from "./rank-kit.js";
|
|
8
|
+
import { fill, voiceOf } from "./locales.js";
|
|
9
|
+
import { drawMark } from "./marks.js";
|
|
10
|
+
import { abbreviate, fit, num } from "./text.js";
|
|
11
|
+
/** The ranks of a list, in order: an entry's own `rank`, else its position, with equal points sharing a rank; `null` for a champion row. Pure; takes the list as it is ordered. */
|
|
12
|
+
export function rankingRanks(entries, options = {}) {
|
|
13
|
+
const ties = options.ties ?? true;
|
|
14
|
+
const out = [];
|
|
15
|
+
let seen = 0;
|
|
16
|
+
let last = null;
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
if (e.champion) {
|
|
19
|
+
out.push(null);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen += 1;
|
|
23
|
+
let rank = seen;
|
|
24
|
+
if (e.rank !== undefined)
|
|
25
|
+
rank = e.rank;
|
|
26
|
+
else if (ties && last && typeof e.points === "number" && last.points === e.points)
|
|
27
|
+
rank = last.rank;
|
|
28
|
+
out.push(rank);
|
|
29
|
+
last = { rank, points: e.points };
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** A category as a heading ("U18 · Men -60 kg"): an age group, a sex and a weight, any of them, joined by a dot. A weight is `-60` or `+100` (kg) or any text. */
|
|
34
|
+
export function rankingCategoryLabel(category, locale) {
|
|
35
|
+
const v = voiceFor(locale);
|
|
36
|
+
const w = v.words;
|
|
37
|
+
const parts = [];
|
|
38
|
+
if (category.age)
|
|
39
|
+
parts.push(category.age);
|
|
40
|
+
const sex = category.sex === "men" ? w.judoMen : category.sex === "women" ? w.judoWomen : undefined;
|
|
41
|
+
const kg = category.weight === undefined ? undefined : /^[-+]\d{2,3}$/.test(category.weight) ? fill(category.weight.startsWith("+") ? w.judoWeightOver : w.judoWeightClass, { sex: sex ?? "", w: category.weight.slice(1) }).trim() : [sex, category.weight].filter(Boolean).join(" ");
|
|
42
|
+
if (kg)
|
|
43
|
+
parts.push(kg);
|
|
44
|
+
else if (sex)
|
|
45
|
+
parts.push(sex);
|
|
46
|
+
return parts.join(" · ");
|
|
47
|
+
}
|
|
48
|
+
const voiceFor = (locale) => voiceOf(locale === undefined ? {} : { locale });
|
|
49
|
+
const nameText = (n, style, v) => {
|
|
50
|
+
if (typeof n === "string")
|
|
51
|
+
return n;
|
|
52
|
+
const given = (n.given ?? "").trim();
|
|
53
|
+
const sur = style === "as-is" ? n.surname.trim() : v.upper(n.surname.trim());
|
|
54
|
+
if (!given)
|
|
55
|
+
return sur;
|
|
56
|
+
return style === "given-first" || style === "as-is" ? `${given} ${sur}` : `${sur} ${given}`;
|
|
57
|
+
};
|
|
58
|
+
const pointsText = (p, precision, v) => {
|
|
59
|
+
if (p === undefined || p === null)
|
|
60
|
+
return "";
|
|
61
|
+
if (typeof p === "string")
|
|
62
|
+
return p;
|
|
63
|
+
if (!Number.isFinite(p))
|
|
64
|
+
return "";
|
|
65
|
+
return precision === undefined ? v.number(p) : new Intl.NumberFormat(v.locale.tag, { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(p);
|
|
66
|
+
};
|
|
67
|
+
function validate(source, options) {
|
|
68
|
+
if (!source || !Array.isArray(source.entries))
|
|
69
|
+
throw new RangeError("taisen-brackets: a ranking needs a list of entries");
|
|
70
|
+
source.entries.forEach((e, i) => {
|
|
71
|
+
const nm = e?.name;
|
|
72
|
+
const ok = typeof nm === "string" ? nm.trim() !== "" : !!nm && typeof nm === "object" && typeof nm.surname === "string" && nm.surname.trim() !== "";
|
|
73
|
+
if (!ok)
|
|
74
|
+
throw new RangeError(`taisen-brackets: entry ${i + 1} of the ranking needs a name`);
|
|
75
|
+
if (e.rank !== undefined && !(Number.isInteger(e.rank) && e.rank >= 1))
|
|
76
|
+
throw new RangeError(`taisen-brackets: the rank of ${typeof nm === "string" ? nm : nm.surname} is a whole number from 1, not ${JSON.stringify(e.rank)}`);
|
|
77
|
+
if (e.change !== undefined && e.change !== "new" && !Number.isInteger(e.change))
|
|
78
|
+
throw new RangeError(`taisen-brackets: the change of ${typeof nm === "string" ? nm : nm.surname} is a whole number of places or "new", not ${JSON.stringify(e.change)}`);
|
|
79
|
+
if (e.points !== undefined && typeof e.points !== "number" && typeof e.points !== "string")
|
|
80
|
+
throw new RangeError(`taisen-brackets: the points of ${typeof nm === "string" ? nm : nm.surname} are a number or a text`);
|
|
81
|
+
});
|
|
82
|
+
const oneOf = (name, value, allowed) => {
|
|
83
|
+
if (value !== undefined && !allowed.includes(value))
|
|
84
|
+
throw new RangeError(`taisen-brackets: ${name} is ${allowed.map((a) => JSON.stringify(a)).join(", ")}, not ${JSON.stringify(value)}`);
|
|
85
|
+
};
|
|
86
|
+
oneOf("a ranking's style", options.style, ["podium", "list", "featured"]);
|
|
87
|
+
oneOf("movement", options.movement, ["arrow", "row", "both", "none"]);
|
|
88
|
+
oneOf("nameStyle", options.nameStyle, ["surname-first", "given-first", "as-is"]);
|
|
89
|
+
oneOf("rankStyle", options.rankStyle, ["plain", "hash"]);
|
|
90
|
+
oneOf("sort", options.sort, ["given", "points"]);
|
|
91
|
+
oneOf("columns", options.columns, [1, 2]);
|
|
92
|
+
if (options.big !== undefined && !(Number.isInteger(options.big) && options.big >= 0 && options.big <= 3))
|
|
93
|
+
throw new RangeError("taisen-brackets: big is a whole number from 0 to 3");
|
|
94
|
+
if (options.rows !== undefined && !(Number.isInteger(options.rows) && options.rows >= 1 && options.rows <= 100))
|
|
95
|
+
throw new RangeError("taisen-brackets: rows is a whole number from 1 to 100");
|
|
96
|
+
if (options.page !== undefined && !(Number.isInteger(options.page) && options.page >= 0))
|
|
97
|
+
throw new RangeError("taisen-brackets: page is a whole number from 0");
|
|
98
|
+
if (options.precision !== undefined && !(Number.isInteger(options.precision) && options.precision >= 0 && options.precision <= 6))
|
|
99
|
+
throw new RangeError("taisen-brackets: precision is a whole number from 0 to 6");
|
|
100
|
+
}
|
|
101
|
+
/** The entries in the order they are drawn, with their ranks. */
|
|
102
|
+
function ordered(source, options) {
|
|
103
|
+
const list = options.sort === "points"
|
|
104
|
+
? source.entries.map((e, i) => ({ e, i })).sort((a, b) => (a.e.champion ? 0 : 1) - (b.e.champion ? 0 : 1) || (typeof b.e.points === "number" ? b.e.points : -Infinity) - (typeof a.e.points === "number" ? a.e.points : -Infinity) || a.i - b.i).map((x) => x.e)
|
|
105
|
+
: [...source.entries];
|
|
106
|
+
const ranks = rankingRanks(list, options.ties === undefined ? {} : { ties: options.ties });
|
|
107
|
+
return list.map((e, i) => ({ e, rank: ranks[i] }));
|
|
108
|
+
}
|
|
109
|
+
/** The ranking as a sentence, the drawing's description. */
|
|
110
|
+
export function describeRanking(source, options = {}) {
|
|
111
|
+
const vv = voiceOf(options);
|
|
112
|
+
const w = vv.words;
|
|
113
|
+
const parts = [source.title ?? w.rankTitle];
|
|
114
|
+
if (source.category)
|
|
115
|
+
parts.push(source.category);
|
|
116
|
+
if (source.date)
|
|
117
|
+
parts.push(source.date);
|
|
118
|
+
const all = ordered(source, options);
|
|
119
|
+
const start = options.rows ? (options.page ?? 0) * options.rows : 0;
|
|
120
|
+
const shown = options.rows ? all.slice(start, start + options.rows) : all;
|
|
121
|
+
if (shown.length === 0)
|
|
122
|
+
parts.push(w.rankNone);
|
|
123
|
+
for (const { e, rank } of shown) {
|
|
124
|
+
const bits = [e.champion ? (e.champion === "interim" ? w.rankInterimLong : w.rankChampionLong) : String(rank), nameText(e.name, options.nameStyle ?? "surname-first", vv)];
|
|
125
|
+
const detail = [e.club, e.country].filter(Boolean).join(", ");
|
|
126
|
+
if (detail)
|
|
127
|
+
bits.push(detail);
|
|
128
|
+
const p = pointsText(e.points, options.precision, vv);
|
|
129
|
+
if (p)
|
|
130
|
+
bits.push(`${p} ${w.rankPoints}`);
|
|
131
|
+
if (e.change === "new")
|
|
132
|
+
bits.push(w.rankNewLong);
|
|
133
|
+
else if (typeof e.change === "number" && e.change !== 0)
|
|
134
|
+
bits.push(fill(e.change > 0 ? w.rankUp : w.rankDown, { n: vv.number(Math.abs(e.change)) }));
|
|
135
|
+
parts.push(bits.join(w.rankJoin));
|
|
136
|
+
}
|
|
137
|
+
return parts.join(w.sep) + w.end;
|
|
138
|
+
}
|
|
139
|
+
const teamFor = (e, id, name, badge) => ({ id, name, abbr: badge, ...(e.flag ? { flag: e.flag } : {}), ...(e.mark ? { mark: e.mark } : {}) });
|
|
140
|
+
/**
|
|
141
|
+
* The ranking list as an SVG. Throws a `RangeError` for an entry with no name, a rank or change that is not a whole number, an option that is not one of its values, or a bad size or theme.
|
|
142
|
+
*/
|
|
143
|
+
export function rankingList(source, options = {}) {
|
|
144
|
+
validate(source, options);
|
|
145
|
+
const style = options.style ?? "podium";
|
|
146
|
+
const f = boardFrame(options, [source, style, options.big, options.columns, options.split, options.rows, options.page, options.medals, options.movement, options.nameStyle, options.rankStyle, options.precision, options.ties, options.sort, options.categories, options.badge]);
|
|
147
|
+
const { W, H, v } = f;
|
|
148
|
+
const w = v.words;
|
|
149
|
+
const unit = Math.min(W, H);
|
|
150
|
+
const nameStyle = options.nameStyle ?? "surname-first";
|
|
151
|
+
const movement = options.movement ?? "arrow";
|
|
152
|
+
const all = ordered(source, options);
|
|
153
|
+
const hasChampion = all.some((x) => x.e.champion);
|
|
154
|
+
const medals = options.medals ?? (style !== "list" && !hasChampion);
|
|
155
|
+
const columns = options.columns ?? (style === "featured" ? 2 : 1);
|
|
156
|
+
const page = options.page ?? 0;
|
|
157
|
+
const bigWanted = page > 0 ? 0 : options.big ?? (style === "podium" ? 3 : style === "featured" ? 1 : 0);
|
|
158
|
+
// The box the content is drawn in: as wide as a row reads well, centred.
|
|
159
|
+
const pad = Math.round(unit * 0.035);
|
|
160
|
+
const split = bigWanted > 0 && all.length - page * (options.rows ?? 0) > Math.min(bigWanted, all.length) && columns === 1 && W / H >= 1.45 && options.split !== false;
|
|
161
|
+
const boxW = Math.min(W - pad * 2, columns === 2 || split ? W - pad * 2 : unit * 1.3);
|
|
162
|
+
const bx = (W - boxW) / 2;
|
|
163
|
+
// The header.
|
|
164
|
+
const title = source.title ?? w.rankTitle;
|
|
165
|
+
const hs = clampN(unit * 0.05, 18, 70);
|
|
166
|
+
const cs = hs * 0.58;
|
|
167
|
+
let y = pad;
|
|
168
|
+
const body = [];
|
|
169
|
+
const dateText = source.date ?? "";
|
|
170
|
+
const dateW = dateText ? Math.min(boxW * 0.4, bw(dateText, cs) + 4) : 0;
|
|
171
|
+
const titleAvail = boxW - (dateW ? dateW + pad : 0);
|
|
172
|
+
const tsz = fitSize(title, hs, titleAvail, 0.5);
|
|
173
|
+
body.push(label(bx, y + hs * 0.85, tsz, fit(title, tsz, titleAvail + 1, bw), "rk-ink", "start", 800));
|
|
174
|
+
if (dateText)
|
|
175
|
+
body.push(label(bx + boxW, y + hs * 0.85, cs, fit(dateText, cs, dateW, bw), "rk-sub", "end", 700));
|
|
176
|
+
y += hs * 1.05;
|
|
177
|
+
const catText = source.category ?? "";
|
|
178
|
+
// The page's range sits on the second line at the right.
|
|
179
|
+
const total = all.length;
|
|
180
|
+
const perPage = options.rows;
|
|
181
|
+
const fromIdx = perPage ? page * perPage : 0;
|
|
182
|
+
if (catText) {
|
|
183
|
+
const p = pill(bx, y + cs * 0.7, cs * 1.35, catText, boxW * 0.62, "rk-accent", "rk-accentink");
|
|
184
|
+
body.push(p.svg);
|
|
185
|
+
}
|
|
186
|
+
y += catText ? cs * 1.35 + pad * 0.7 : pad * 0.35;
|
|
187
|
+
const footerH = source.source ? Math.max(14, unit * 0.026) + pad * 0.5 : 0;
|
|
188
|
+
const bodyTop = y;
|
|
189
|
+
const bodyBottom = H - pad - footerH;
|
|
190
|
+
const avail = bodyBottom - bodyTop;
|
|
191
|
+
// Which entries fit.
|
|
192
|
+
const cols = columns;
|
|
193
|
+
const minComp = Math.max(30, unit * 0.04);
|
|
194
|
+
const k = cols === 2 && style === "featured" ? 2.3 : 2.1;
|
|
195
|
+
const GAP = 0.14;
|
|
196
|
+
// How many entries fit when each compact row is at least `minComp` high.
|
|
197
|
+
const fitCount = (nBig) => nBig + cols * Math.max(1, Math.floor((split ? avail / minComp : avail / minComp - nBig * (k + GAP)) / (1 + GAP)));
|
|
198
|
+
const wanted = perPage ?? total - fromIdx;
|
|
199
|
+
const bigCap = Math.min(bigWanted, Math.max(0, total - fromIdx));
|
|
200
|
+
const count = Math.max(0, Math.min(wanted, total - fromIdx, perPage ? wanted : fitCount(bigCap)));
|
|
201
|
+
const slice = all.slice(fromIdx, fromIdx + count);
|
|
202
|
+
const nBig = Math.min(bigCap, slice.length);
|
|
203
|
+
const nComp = slice.length - nBig;
|
|
204
|
+
const rpc = Math.ceil(nComp / cols);
|
|
205
|
+
const denom = split ? rpc * (1 + GAP) : nBig * (k + GAP) + rpc * (1 + GAP);
|
|
206
|
+
const maxComp = unit * 0.11;
|
|
207
|
+
const compH = denom > 0 ? Math.min(maxComp, avail / denom) : maxComp;
|
|
208
|
+
// Side by side, the big rows stack in their own panel and take its whole height; stacked, they are `k` times a compact row.
|
|
209
|
+
const bigH = split ? Math.min(unit * 0.27, avail / (nBig * (1 + GAP))) : compH * k;
|
|
210
|
+
const gap = compH * GAP;
|
|
211
|
+
const bigGap = split ? bigH * GAP : gap;
|
|
212
|
+
const leftW = split ? Math.round((boxW - pad) * 0.44) : boxW;
|
|
213
|
+
// Rows, worked out.
|
|
214
|
+
const marksAny = options.badge === true || slice.some((x) => x.e.flag || x.e.mark);
|
|
215
|
+
const rows = slice.map(({ e, rank }, i) => {
|
|
216
|
+
const name = nameText(e.name, nameStyle, v);
|
|
217
|
+
const medal = medals && rank !== null && rank <= 3 ? rank : e.champion ? 1 : 0;
|
|
218
|
+
const rankText = e.champion ? (e.champion === "interim" ? w.rankInterim : w.rankChampion) : `${options.rankStyle === "hash" ? "#" : ""}${v.number(rank ?? 0)}`;
|
|
219
|
+
const second = [e.club ?? e.country, options.categories ? e.category : undefined].filter((x) => !!x).join(" · ");
|
|
220
|
+
const badge = abbreviate(e.club ?? name).slice(0, 3);
|
|
221
|
+
return { e, rank, rankText, name, second, code: e.code ?? "", points: pointsText(e.points, options.precision, v), medal, change: e.change, team: teamFor(e, `r${fromIdx + i}`, name, badge) };
|
|
222
|
+
});
|
|
223
|
+
const bigRows = rows.slice(0, nBig), compRows = rows.slice(nBig);
|
|
224
|
+
const bigM = metrics(f, bigRows, bigH, true, movement, marksAny, leftW);
|
|
225
|
+
const colGap = pad;
|
|
226
|
+
const colW = split ? boxW - leftW - pad : cols === 2 ? (boxW - colGap) / 2 : boxW;
|
|
227
|
+
const compM = metrics(f, compRows, compH, false, movement, marksAny, colW);
|
|
228
|
+
let cy = bodyTop;
|
|
229
|
+
bigRows.forEach((row) => {
|
|
230
|
+
drawRow(body, f, { x: bx, y: cy, w: leftW, h: bigH }, row, bigM, true, movement);
|
|
231
|
+
cy += bigH + bigGap;
|
|
232
|
+
});
|
|
233
|
+
const compTop = split ? bodyTop : cy;
|
|
234
|
+
const compX = split ? bx + leftW + pad : bx;
|
|
235
|
+
compRows.forEach((row, i) => {
|
|
236
|
+
const col = Math.floor(i / rpc), idx = i % rpc;
|
|
237
|
+
drawRow(body, f, { x: compX + col * (colW + colGap), y: compTop + idx * (compH + gap), w: colW, h: compH }, row, compM, false, movement);
|
|
238
|
+
});
|
|
239
|
+
if (rows.length === 0)
|
|
240
|
+
body.push(label(bx, bodyTop + hs, cs, w.rankNone, "rk-sub", "start", 700));
|
|
241
|
+
// The range at the right of the category line, where entries are paged.
|
|
242
|
+
if (perPage && total > perPage && rows.length) {
|
|
243
|
+
const t = fill(w.rankRange, { a: v.number(fromIdx + 1), b: v.number(fromIdx + rows.length), n: v.number(total) });
|
|
244
|
+
body.push(label(bx + boxW, bodyTop - pad * 0.7 - cs * 0.3, cs * 0.85, fit(t, cs * 0.85, boxW * 0.35, bw), "rk-sub", "end", 700));
|
|
245
|
+
}
|
|
246
|
+
if (source.source) {
|
|
247
|
+
const fs = Math.max(12, unit * 0.022);
|
|
248
|
+
body.push(label(bx, H - pad - fs * 0.2, fs, fit(source.source, fs, boxW, bw), "rk-sub", "start", 600));
|
|
249
|
+
}
|
|
250
|
+
const desc = describeRanking(source, options);
|
|
251
|
+
return boardWrap(f, options.title ?? [title, source.category].filter(Boolean).join(": "), desc, body, "ranking");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The widths of the columns a class of rows shares, so that the ranks, arrows, flags and points line up down the list. Where the row is narrow for them the type steps down until the
|
|
255
|
+
* name has room, and the code (which only repeats the flag) goes first.
|
|
256
|
+
*/
|
|
257
|
+
function metrics(f, rows, h, big, movement, marksAny, rowW) {
|
|
258
|
+
const v = f.v;
|
|
259
|
+
const he = Math.min(h, rowW / (big ? 4.4 : 7.2));
|
|
260
|
+
const showMove = movement !== "none" && rows.some((r) => r.change !== undefined);
|
|
261
|
+
const moveText = (r) => (r.change === "new" ? v.words.rankNew : typeof r.change === "number" && r.change !== 0 ? v.number(Math.abs(r.change)) : "");
|
|
262
|
+
const longest = Math.max(0, ...rows.map((r) => bw(r.name, he * (big ? 0.34 : 0.4) * 0.62)));
|
|
263
|
+
const need = Math.min(rowW * 0.42, longest);
|
|
264
|
+
let m;
|
|
265
|
+
for (const sc of [1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52]) {
|
|
266
|
+
const e = he * sc;
|
|
267
|
+
const pad = e * 0.2;
|
|
268
|
+
const rs = e * (big ? 0.56 : 0.5);
|
|
269
|
+
const rankW = Math.max(e * 0.7, ...rows.map((r) => bw(r.rankText, rs))) + e * 0.2;
|
|
270
|
+
const ms = e * (big ? 0.26 : 0.3);
|
|
271
|
+
const moveW = showMove ? Math.max(ms * 1.4, ...rows.map((r) => (r.change === "new" ? bw(moveText(r), ms * 0.9) + ms * 0.9 : r.change === undefined ? 0 : ms * 1.1 + ms * 0.35 + bw(moveText(r), ms)))) + e * 0.16 : 0;
|
|
272
|
+
const d = e * (big ? 0.52 : 0.6);
|
|
273
|
+
const markW = marksAny ? d + e * 0.2 : 0;
|
|
274
|
+
const ps = e * 0.4;
|
|
275
|
+
const pointsRaw = Math.max(0, ...rows.map((r) => (r.points ? bw(r.points, ps) : 0)));
|
|
276
|
+
const bigLabel = big && pointsRaw > 0 ? bw(v.upper(v.words.rankPoints), Math.max(ps * 0.3, 10)) : 0;
|
|
277
|
+
const suffixW = !big && pointsRaw > 0 ? bw(v.words.rankPointsShort, Math.max(ps * 0.5, 10)) + ps * 0.3 : 0;
|
|
278
|
+
const pointsW = Math.max(pointsRaw + suffixW, bigLabel);
|
|
279
|
+
const cs = e * 0.3;
|
|
280
|
+
const codeW = !big && rows.some((r) => r.code) ? Math.max(...rows.map((r) => bw(r.code, cs))) + e * 0.2 : 0;
|
|
281
|
+
const ns = e * (big ? 0.34 : 0.4);
|
|
282
|
+
const ss = e * (big ? 0.19 : 0.26);
|
|
283
|
+
const fixed = pad * 2 + rankW + moveW + markW + pointsW + (big ? pad * 1.8 : pad * 0.8);
|
|
284
|
+
const nameCol = Math.min(Math.max(0, ...rows.map((r) => bw(r.name, ns))) + pad, rowW);
|
|
285
|
+
m = { he: e, rs, rankW, ms, moveW, d, markW, ps, pointsW, ns, ss, cs, codeW: 0, pad, nameCol };
|
|
286
|
+
if (rowW - fixed >= need) {
|
|
287
|
+
if (rowW - fixed - codeW >= need * 1.3)
|
|
288
|
+
m.codeW = codeW;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return m;
|
|
293
|
+
}
|
|
294
|
+
function drawRow(out, f, r, row, m, big, movement) {
|
|
295
|
+
const { v } = f;
|
|
296
|
+
const w = v.words;
|
|
297
|
+
const g = [];
|
|
298
|
+
const tint = row.medal ? `rk-medal${row.medal}` : "";
|
|
299
|
+
const mv = typeof row.change === "number" && row.change !== 0 ? (row.change > 0 ? "up" : "down") : "";
|
|
300
|
+
const tintMove = (movement === "row" || movement === "both") && mv && !row.medal ? `rk-m${mv}` : "";
|
|
301
|
+
const rx = Math.min(r.h * 0.18, 18);
|
|
302
|
+
g.push(`<rect class="${["rk-row", tint, tintMove].filter(Boolean).join(" ")}" x="${num(r.x)}" y="${num(r.y)}" width="${num(r.w)}" height="${num(r.h)}" rx="${num(rx)}"${row.medal ? ` fill="url(#${f.scope}-g${row.medal})"` : ""}/>`);
|
|
303
|
+
const cy = r.y + r.h / 2;
|
|
304
|
+
let x = r.x + m.pad;
|
|
305
|
+
const right = r.x + r.w - m.pad;
|
|
306
|
+
// The rank.
|
|
307
|
+
const rankCls = row.medal ? `rk-num${row.medal}` : "rk-ink";
|
|
308
|
+
g.push(label(x + (m.rankW - m.pad * 0.2) / 2, cy + m.rs * 0.36, m.rs, row.rankText, rankCls, "middle", 800));
|
|
309
|
+
x += m.rankW;
|
|
310
|
+
// The movement: an arrow and the number of places, or NR.
|
|
311
|
+
if (m.moveW > 0) {
|
|
312
|
+
if (row.change === "new") {
|
|
313
|
+
const p = pill(x, cy, m.ms * 1.35, w.rankNew, m.moveW, "rk-nr", "rk-nrink");
|
|
314
|
+
g.push(p.svg);
|
|
315
|
+
}
|
|
316
|
+
else if (mv && (movement === "arrow" || movement === "both" || movement === "row")) {
|
|
317
|
+
const n = v.number(Math.abs(row.change));
|
|
318
|
+
if (movement !== "row") {
|
|
319
|
+
g.push(arrow(x + m.ms * 0.55, cy, m.ms * 0.85, mv === "up"));
|
|
320
|
+
g.push(label(x + m.ms * 1.1 + m.ms * 0.3, cy + m.ms * 0.36, m.ms, n, mv === "up" ? "rk-up" : "rk-down", "start", 800));
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
g.push(label(x, cy + m.ms * 0.36, m.ms, `${mv === "up" ? "+" : "−"}${n}`, mv === "up" ? "rk-up" : "rk-down", "start", 800));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else if (row.change === 0) {
|
|
327
|
+
g.push(label(x + m.ms * 0.55, cy + m.ms * 0.36, m.ms, "–", "rk-sub", "middle", 800));
|
|
328
|
+
}
|
|
329
|
+
x += m.moveW;
|
|
330
|
+
}
|
|
331
|
+
// The flag, a mark, or initials.
|
|
332
|
+
if (m.markW > 0) {
|
|
333
|
+
g.push(drawMark(row.team, x + m.d / 2, cy, m.d, f.ctx));
|
|
334
|
+
x += m.markW;
|
|
335
|
+
}
|
|
336
|
+
// The points, at the right.
|
|
337
|
+
let nameRight = right;
|
|
338
|
+
if (m.pointsW > 0 && row.points) {
|
|
339
|
+
if (big) {
|
|
340
|
+
const sepX = right - m.pointsW - m.pad * 0.9;
|
|
341
|
+
g.push(`<line class="rk-sep" x1="${num(sepX)}" x2="${num(sepX)}" y1="${num(r.y + r.h * 0.2)}" y2="${num(r.y + r.h * 0.8)}"/>`);
|
|
342
|
+
const ps = fitSize(row.points, m.ps, m.pointsW, 0.5);
|
|
343
|
+
const ls = Math.max(m.ps * 0.3, 10);
|
|
344
|
+
g.push(label(right - m.pointsW / 2, cy + ps * 0.2, ps, row.points, "rk-ink", "middle", 800));
|
|
345
|
+
g.push(label(right - m.pointsW / 2, cy + ps * 0.2 + ls * 1.6, ls, fit(v.upper(w.rankPoints), ls, m.pointsW, bw), "rk-sub", "middle", 700, ' letter-spacing=".12em"'));
|
|
346
|
+
nameRight = sepX - m.pad * 0.9;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const suffix = w.rankPointsShort;
|
|
350
|
+
const sz = Math.max(m.ps * 0.5, 10);
|
|
351
|
+
const sw = bw(suffix, sz);
|
|
352
|
+
g.push(label(right, cy + sz * 0.36, sz, suffix, "rk-sub", "end", 700));
|
|
353
|
+
g.push(label(right - sw - m.ps * 0.3, cy + m.ps * 0.36, m.ps, row.points, "rk-ink", "end", 800));
|
|
354
|
+
nameRight = right - m.pointsW - m.pad * 0.8;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (m.pointsW > 0) {
|
|
358
|
+
nameRight = right - m.pointsW - m.pad * 0.8;
|
|
359
|
+
}
|
|
360
|
+
// The code, then the name and what goes under or beside it.
|
|
361
|
+
let codeX = nameRight;
|
|
362
|
+
if (m.codeW > 0 && !big) {
|
|
363
|
+
codeX = nameRight - m.codeW;
|
|
364
|
+
if (row.code)
|
|
365
|
+
g.push(label(codeX + m.codeW, cy + m.cs * 0.36, m.cs, row.code, "rk-sub", "end", 700));
|
|
366
|
+
nameRight = codeX - m.pad * 0.3;
|
|
367
|
+
}
|
|
368
|
+
const avail = Math.max(m.ns, nameRight - x);
|
|
369
|
+
const twoLine = big || m.he >= 42;
|
|
370
|
+
if (twoLine && row.second) {
|
|
371
|
+
const ns = fitSize(row.name, m.ns, avail, 0.5);
|
|
372
|
+
const ss = fitSize(row.second, m.ss, avail, 0.6);
|
|
373
|
+
const block = ns * 1.05 + ss * 1.3;
|
|
374
|
+
const top = cy - block / 2;
|
|
375
|
+
g.push(label(x, top + ns * 0.86, ns, fit(row.name, ns, avail + 1, bw), "rk-ink", "start", 800));
|
|
376
|
+
g.push(label(x, top + ns * 1.05 + ss * 1.05, ss, fit(row.second, ss, avail + 1, bw), "rk-sub", "start", 600));
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const club = row.second;
|
|
380
|
+
const nameAvail0 = Math.min(avail, m.nameCol + m.pad);
|
|
381
|
+
const clubW = club && avail - nameAvail0 > m.he * 3 ? avail - nameAvail0 : 0;
|
|
382
|
+
const nameAvail = clubW ? nameAvail0 : avail;
|
|
383
|
+
const ns = fitSize(row.name, m.ns, nameAvail, 0.55);
|
|
384
|
+
g.push(label(x, cy + ns * 0.36, ns, fit(row.name, ns, nameAvail + 1, bw), "rk-ink", "start", 800));
|
|
385
|
+
if (clubW) {
|
|
386
|
+
const ss = m.ss * 1.05;
|
|
387
|
+
g.push(label(x + nameAvail, cy + ss * 0.36, ss, fit(club, ss, clubW + 1, bw), "rk-sub", "start", 600));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
out.push(`<g class="rk-entry">${g.join("")}</g>`);
|
|
391
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -397,6 +397,23 @@ export interface Words {
|
|
|
397
397
|
/** The default titles of the contest card and of the mat queue board. */
|
|
398
398
|
judoCardTitle: string;
|
|
399
399
|
judoQueueTitle: string;
|
|
400
|
+
/** Ranking lists (`rankingList`): the heading, "points" and its short form, the champion's mark ("C") and its words, the interim champion's, newly ranked ("NR") and its words, the movement in a sentence (`{n}` places), no change, which entries are shown (`{a}`, `{b}`, `{n}`) and an empty list. */
|
|
401
|
+
rankTitle: string;
|
|
402
|
+
rankPoints: string;
|
|
403
|
+
rankPointsShort: string;
|
|
404
|
+
rankChampion: string;
|
|
405
|
+
rankChampionLong: string;
|
|
406
|
+
rankInterim: string;
|
|
407
|
+
rankInterimLong: string;
|
|
408
|
+
rankNew: string;
|
|
409
|
+
rankNewLong: string;
|
|
410
|
+
rankUp: string;
|
|
411
|
+
rankDown: string;
|
|
412
|
+
rankSame: string;
|
|
413
|
+
rankRange: string;
|
|
414
|
+
rankNone: string;
|
|
415
|
+
/** What joins the pieces of one entry in the description ("1, NAKAMURA Kenji, 412.50 points"). */
|
|
416
|
+
rankJoin: string;
|
|
400
417
|
}
|
|
401
418
|
/** A language: its words, the names of the rounds (by their English names), and the tag `Intl` formats numbers and dates for. */
|
|
402
419
|
export interface Locale {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.11.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.11.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/taisen-brackets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Taisen Brackets 対戦, knockout brackets as SVG: from a list of ties (games, best-of series, two legs, replays, byes, a third-place match) it works out the rounds and halves and draws the bracket with the halves meeting at the final, or left to right, wide, square, as a tall card or for a phone, in a classic or a road look (large round flags, big scores, a team's dotted path, a link on every box), in light, dark or a gold-on-navy poster look, in eight layouts (the tree, rows, shields, a score-update card, tall columns, the NBA's play-in, a series card and group tables), on one of twenty-five poster backgrounds (the playing surfaces of five sports among them) or your own, with each team's logo or round flag, the champion in the centre, and an accessible table; and the road to the final of either finalist. NHL, NBA, MLB, NFL, CFL, MLS and World Cup formats by era, in English, French, Japanese, Spanish and German. Zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bracket",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@eslint/js": "^9.0.0",
|
|
120
120
|
"@johnmorrisdotca/dougu-sports": "0.1.0",
|
|
121
|
-
"@johnmorrisdotca/hata": "1.
|
|
121
|
+
"@johnmorrisdotca/hata": "1.6.2",
|
|
122
122
|
"@playwright/test": "^1.63.0",
|
|
123
123
|
"@types/node": "^24.0.0",
|
|
124
124
|
"eslint": "^9.0.0",
|