@johnmorrisdotca/taisen-brackets 0.0.0-stage → 0.1.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 +25 -0
- package/LICENSE +21 -0
- package/NOTICE.md +19 -0
- package/README.md +392 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/layout-entry.d.ts +6 -0
- package/dist/layout-entry.js +5 -0
- package/dist/layout.d.ts +8 -0
- package/dist/layout.js +261 -0
- package/dist/marks.d.ts +31 -0
- package/dist/marks.js +84 -0
- package/dist/matchup.d.ts +11 -0
- package/dist/matchup.js +147 -0
- package/dist/presets-entry.d.ts +3 -0
- package/dist/presets-entry.js +2 -0
- package/dist/presets.d.ts +43 -0
- package/dist/presets.js +81 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.js +478 -0
- package/dist/rounds.d.ts +8 -0
- package/dist/rounds.js +26 -0
- package/dist/seeding.d.ts +60 -0
- package/dist/seeding.js +141 -0
- package/dist/table.d.ts +14 -0
- package/dist/table.js +35 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.js +62 -0
- package/dist/theme.d.ts +21 -0
- package/dist/theme.js +53 -0
- package/dist/types.d.ts +281 -0
- package/dist/types.js +5 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/dist/words.d.ts +7 -0
- package/dist/words.js +33 -0
- package/package.json +100 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Matchup, SideId, Team } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The seeds in the order a standard bracket lists them, so that 1 meets 2 only in the final and the top seeds meet the
|
|
4
|
+
* bottom ones first: `standardSeeds(8)` is `[1, 8, 4, 5, 2, 7, 3, 6]`. `size` is rounded up to a power of two.
|
|
5
|
+
*/
|
|
6
|
+
export declare function standardSeeds(size: number): number[];
|
|
7
|
+
export interface SingleEliminationOptions {
|
|
8
|
+
/** Add a third-place match between the losing semi-finalists. Default false. */
|
|
9
|
+
thirdPlace?: boolean;
|
|
10
|
+
/** The ids of the ties are `${prefix}1-1`, `${prefix}1-2`… (round, then place in the round). Default "R". */
|
|
11
|
+
prefix?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The matchups of a single-elimination bracket for any number of teams, seeded in the standard way: the top seeds meet
|
|
15
|
+
* the lowest first, and when the number is not a power of two the top seeds have byes. The teams are in seed order
|
|
16
|
+
* (or carry a `seed`). No tie has a result yet; fill the results in and call `advanceWinners` to carry them forward.
|
|
17
|
+
*/
|
|
18
|
+
export declare function singleElimination(teams: readonly Team[], options?: SingleEliminationOptions): {
|
|
19
|
+
teams: Team[];
|
|
20
|
+
matchups: Matchup[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Fills the sides of every tie from the ties that feed it: a decided tie's winner takes its place in the next tie (the
|
|
24
|
+
* loser, for a third-place match). Returns new matchups; the ones given are not changed. Ties are filled in the order of
|
|
25
|
+
* the rounds, so a result for a later round is read against the sides this has just filled in.
|
|
26
|
+
*/
|
|
27
|
+
export declare function advanceWinners(matchups: readonly Matchup[]): Matchup[];
|
|
28
|
+
/** One played game, in the flat shape that most data sources give. */
|
|
29
|
+
export interface PlayedGame {
|
|
30
|
+
/** The stage it was played in: a key from `stages`, or the `third` one. */
|
|
31
|
+
stage: string;
|
|
32
|
+
home: SideId;
|
|
33
|
+
away: SideId;
|
|
34
|
+
homeScore: number;
|
|
35
|
+
awayScore: number;
|
|
36
|
+
date?: string;
|
|
37
|
+
extraTime?: boolean;
|
|
38
|
+
homePens?: number;
|
|
39
|
+
awayPens?: number;
|
|
40
|
+
/** A replay of a drawn game. */
|
|
41
|
+
replay?: boolean;
|
|
42
|
+
/** An order within the stage (a match number). */
|
|
43
|
+
no?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface GamesOptions {
|
|
46
|
+
/** The knockout stages in order, first round first: `["round-of-16", "quarter-final", "semi-final", "final"]`. */
|
|
47
|
+
stages: readonly string[];
|
|
48
|
+
/** The stage of the third-place match, if the data has one. */
|
|
49
|
+
third?: string;
|
|
50
|
+
/** Stages played in two legs, added up. Several games of one pair in any other stage are a replay. */
|
|
51
|
+
aggregate?: readonly string[];
|
|
52
|
+
/** Stages played as a series, with their length: `{ final: 7 }`. */
|
|
53
|
+
series?: Readonly<Record<string, number>>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Matchups from a flat list of games: the games of one pair in one stage become one tie (a replay, two legs or a series),
|
|
57
|
+
* the stage orders the rounds, and the third-place stage becomes the third-place match. The sides of a tie are the home
|
|
58
|
+
* and away of its first game; each game's score is turned to that order.
|
|
59
|
+
*/
|
|
60
|
+
export declare function matchupsFromGames(games: readonly PlayedGame[], options: GamesOptions): Matchup[];
|
package/dist/seeding.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { collectTeams, resolveMatchup } from "./matchup.js";
|
|
2
|
+
/**
|
|
3
|
+
* The seeds in the order a standard bracket lists them, so that 1 meets 2 only in the final and the top seeds meet the
|
|
4
|
+
* bottom ones first: `standardSeeds(8)` is `[1, 8, 4, 5, 2, 7, 3, 6]`. `size` is rounded up to a power of two.
|
|
5
|
+
*/
|
|
6
|
+
export function standardSeeds(size) {
|
|
7
|
+
if (!Number.isInteger(size) || size < 1)
|
|
8
|
+
throw new Error(`taisen-brackets: standardSeeds needs a whole number of 1 or more, not ${size}`);
|
|
9
|
+
let seeds = [1];
|
|
10
|
+
while (seeds.length < size) {
|
|
11
|
+
const total = seeds.length * 2;
|
|
12
|
+
seeds = seeds.flatMap((s) => [s, total + 1 - s]);
|
|
13
|
+
}
|
|
14
|
+
return seeds;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The matchups of a single-elimination bracket for any number of teams, seeded in the standard way: the top seeds meet
|
|
18
|
+
* the lowest first, and when the number is not a power of two the top seeds have byes. The teams are in seed order
|
|
19
|
+
* (or carry a `seed`). No tie has a result yet; fill the results in and call `advanceWinners` to carry them forward.
|
|
20
|
+
*/
|
|
21
|
+
export function singleElimination(teams, options = {}) {
|
|
22
|
+
const count = teams.length;
|
|
23
|
+
if (count < 2)
|
|
24
|
+
throw new Error("taisen-brackets: a bracket needs at least two teams");
|
|
25
|
+
const bySeed = teams.every((t) => t.seed !== undefined) ? [...teams].sort((a, b) => a.seed - b.seed) : [...teams];
|
|
26
|
+
const seeded = bySeed.map((t, i) => ({ ...t, seed: t.seed ?? i + 1 }));
|
|
27
|
+
const slots = standardSeeds(count);
|
|
28
|
+
const size = slots.length;
|
|
29
|
+
const rounds = Math.log2(size);
|
|
30
|
+
const prefix = options.prefix ?? "R";
|
|
31
|
+
const teamAt = (seed) => seeded.find((t) => t.seed === seed) ?? null;
|
|
32
|
+
const matchups = [];
|
|
33
|
+
const node = (round, k) => {
|
|
34
|
+
if (round === 1) {
|
|
35
|
+
const a = teamAt(slots[2 * k]), b = teamAt(slots[2 * k + 1]);
|
|
36
|
+
if (a && !b)
|
|
37
|
+
return { team: a };
|
|
38
|
+
if (b && !a)
|
|
39
|
+
return { team: b };
|
|
40
|
+
const id = `${prefix}1-${k + 1}`;
|
|
41
|
+
matchups.push({ id, round: 1, sides: [a.id, b.id] });
|
|
42
|
+
return { tie: id };
|
|
43
|
+
}
|
|
44
|
+
const left = node(round - 1, 2 * k), right = node(round - 1, 2 * k + 1);
|
|
45
|
+
const id = `${prefix}${round}-${k + 1}`;
|
|
46
|
+
const side = (n) => ("team" in n ? n.team.id : null);
|
|
47
|
+
const feed = (n) => ("tie" in n ? n.tie : null);
|
|
48
|
+
matchups.push({ id, round, sides: [side(left), side(right)], feeds: [feed(left), feed(right)] });
|
|
49
|
+
return { tie: id };
|
|
50
|
+
};
|
|
51
|
+
node(rounds, 0);
|
|
52
|
+
if (options.thirdPlace && rounds >= 2) {
|
|
53
|
+
const semis = matchups.filter((m) => m.round === rounds - 1).map((m) => m.id);
|
|
54
|
+
matchups.push({ id: `${prefix}3rd`, round: rounds, third: true, sides: [null, null], feeds: [semis[0] ?? null, semis[1] ?? null] });
|
|
55
|
+
}
|
|
56
|
+
return { teams: seeded, matchups };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fills the sides of every tie from the ties that feed it: a decided tie's winner takes its place in the next tie (the
|
|
60
|
+
* loser, for a third-place match). Returns new matchups; the ones given are not changed. Ties are filled in the order of
|
|
61
|
+
* the rounds, so a result for a later round is read against the sides this has just filled in.
|
|
62
|
+
*/
|
|
63
|
+
export function advanceWinners(matchups) {
|
|
64
|
+
let current = matchups.map((m) => ({ ...m }));
|
|
65
|
+
for (let pass = 0; pass < current.length; pass++) {
|
|
66
|
+
const teams = collectTeams(current);
|
|
67
|
+
// A tie whose winner is named before its sides are filled in cannot be read yet; it is undecided until they are.
|
|
68
|
+
const at = new Map();
|
|
69
|
+
current.forEach((m, i) => { try {
|
|
70
|
+
const t = resolveMatchup(m, i, teams);
|
|
71
|
+
at.set(t.id, t);
|
|
72
|
+
}
|
|
73
|
+
catch { /* not yet */ } });
|
|
74
|
+
let changed = false;
|
|
75
|
+
current = current.map((m) => {
|
|
76
|
+
if (!m.feeds)
|
|
77
|
+
return m;
|
|
78
|
+
let touched = false;
|
|
79
|
+
const sides = [...m.sides];
|
|
80
|
+
m.feeds.forEach((feed, k) => {
|
|
81
|
+
if (feed == null)
|
|
82
|
+
return;
|
|
83
|
+
const child = at.get(feed);
|
|
84
|
+
if (!child || child.winner === null)
|
|
85
|
+
return;
|
|
86
|
+
const team = child.sides[m.third ? (1 - child.winner) : child.winner];
|
|
87
|
+
const here = sides[k];
|
|
88
|
+
if (team && (here !== null && typeof here === "object" ? here.id : here) !== team.id) {
|
|
89
|
+
sides[k] = team.id;
|
|
90
|
+
touched = true;
|
|
91
|
+
changed = true;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return touched ? { ...m, sides } : m;
|
|
95
|
+
});
|
|
96
|
+
if (!changed)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
return current;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Matchups from a flat list of games: the games of one pair in one stage become one tie (a replay, two legs or a series),
|
|
103
|
+
* the stage orders the rounds, and the third-place stage becomes the third-place match. The sides of a tie are the home
|
|
104
|
+
* and away of its first game; each game's score is turned to that order.
|
|
105
|
+
*/
|
|
106
|
+
export function matchupsFromGames(games, options) {
|
|
107
|
+
const sorted = [...games].sort((a, b) => (a.date ?? "") < (b.date ?? "") ? -1 : (a.date ?? "") > (b.date ?? "") ? 1 : 0);
|
|
108
|
+
const groups = new Map();
|
|
109
|
+
for (const g of sorted) {
|
|
110
|
+
if (g.stage !== options.third && !options.stages.includes(g.stage))
|
|
111
|
+
throw new Error(`taisen-brackets: game stage ${JSON.stringify(g.stage)} is not in stages`);
|
|
112
|
+
const pair = [String(g.home), String(g.away)].sort().join("|");
|
|
113
|
+
const key = `${g.stage}|${pair}`;
|
|
114
|
+
groups.set(key, [...(groups.get(key) ?? []), g]);
|
|
115
|
+
}
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const list of groups.values()) {
|
|
118
|
+
const head = list[0];
|
|
119
|
+
const sides = [head.home, head.away];
|
|
120
|
+
const turned = list.map((g) => {
|
|
121
|
+
const flip = g.home !== head.home;
|
|
122
|
+
const pair = (a, b) => (flip ? [b, a] : [a, b]);
|
|
123
|
+
const pens = g.homePens !== undefined && g.awayPens !== undefined ? pair(g.homePens, g.awayPens) : undefined;
|
|
124
|
+
return { score: pair(g.homeScore, g.awayScore), date: g.date, extraTime: g.extraTime, pens, replay: g.replay };
|
|
125
|
+
});
|
|
126
|
+
const third = head.stage === options.third;
|
|
127
|
+
const round = third ? options.stages.length : options.stages.indexOf(head.stage) + 1;
|
|
128
|
+
const m = { id: `${head.stage}-${out.length + 1}`, sides, round, games: turned, no: head.no, third };
|
|
129
|
+
if (options.aggregate?.includes(head.stage))
|
|
130
|
+
m.kind = "aggregate";
|
|
131
|
+
else if (options.series?.[head.stage]) {
|
|
132
|
+
m.kind = "series";
|
|
133
|
+
m.bestOf = options.series[head.stage];
|
|
134
|
+
}
|
|
135
|
+
else if (list.length > 1)
|
|
136
|
+
turned.forEach((g, i) => { if (i > 0)
|
|
137
|
+
g.replay = true; });
|
|
138
|
+
out.push(m);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BracketInput, BracketLayout, DrawOptions, Matchup, Words } from "./types.ts";
|
|
2
|
+
export interface TableOptions {
|
|
3
|
+
words?: Partial<Words>;
|
|
4
|
+
roundNames?: DrawOptions["roundNames"];
|
|
5
|
+
/** The table's caption. Default: the summary ("16 teams, 4 rounds"). */
|
|
6
|
+
caption?: string;
|
|
7
|
+
/** A class for the `<table>`. Default `tb-table`. */
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The bracket as an HTML table, for a reader the drawing does not serve: one row for every tie, round by round, with the
|
|
12
|
+
* two sides, the score, the winner in bold and a note. It is a plain, valid `<table>` with a caption and header cells.
|
|
13
|
+
*/
|
|
14
|
+
export declare function bracketTable(input: BracketLayout | readonly Matchup[] | BracketInput, options?: TableOptions): string;
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { layoutBracket } from "./layout.js";
|
|
2
|
+
import { noteParts } from "./matchup.js";
|
|
3
|
+
import { roundLabel } from "./rounds.js";
|
|
4
|
+
import { esc } from "./text.js";
|
|
5
|
+
import { fill, wordsWith } from "./words.js";
|
|
6
|
+
/**
|
|
7
|
+
* The bracket as an HTML table, for a reader the drawing does not serve: one row for every tie, round by round, with the
|
|
8
|
+
* two sides, the score, the winner in bold and a note. It is a plain, valid `<table>` with a caption and header cells.
|
|
9
|
+
*/
|
|
10
|
+
export function bracketTable(input, options = {}) {
|
|
11
|
+
const layout = "ties" in input && "rounds" in input ? input : layoutBracket(input);
|
|
12
|
+
const words = wordsWith(options.words);
|
|
13
|
+
const caption = options.caption ?? fill(layout.rounds === 1 ? words.summaryOne : words.summary, { n: layout.teams.length, r: layout.rounds });
|
|
14
|
+
const order = layout.ties
|
|
15
|
+
.filter((t) => t.round > 0 || layout.robin.includes(t.index))
|
|
16
|
+
.sort((a, b) => Number(a.third) - Number(b.third) || a.round - b.round || (a.date < b.date ? -1 : a.date > b.date ? 1 : a.index - b.index));
|
|
17
|
+
const name = (t, k) => {
|
|
18
|
+
const team = t.sides[k];
|
|
19
|
+
const text = team ? esc(team.name) : esc(words.tbd);
|
|
20
|
+
return t.winner === k ? `<strong>${text}</strong>` : text;
|
|
21
|
+
};
|
|
22
|
+
const score = (t) => (t.result.score ? `${t.result.score[0]}–${t.result.score[1]}` : "");
|
|
23
|
+
const rows = order.map((t) => {
|
|
24
|
+
const roundText = t.third ? words.thirdPlace : t.round === 0 ? "" : roundLabel(layout, t.round, options.roundNames, words);
|
|
25
|
+
const note = noteParts(t, words).map((part) => (part === words.pens && t.result.pens ? `${words.pens} ${t.result.pens[0]}–${t.result.pens[1]}` : part)).join(", ");
|
|
26
|
+
return `<tr><th scope="row">${esc(roundText)}</th><td>${name(t, 0)}</td><td>${esc(score(t))}</td><td>${name(t, 1)}</td><td>${esc(note)}</td></tr>`;
|
|
27
|
+
});
|
|
28
|
+
return [
|
|
29
|
+
`<table class="${esc(options.className ?? "tb-table")}">`,
|
|
30
|
+
`<caption>${esc(caption)}</caption>`,
|
|
31
|
+
`<thead><tr><th scope="col">${esc(words.columnRound)}</th><th scope="col" colspan="3">${esc(words.columnTie)}</th><th scope="col">${esc(words.columnNote)}</th></tr></thead>`,
|
|
32
|
+
`<tbody>${rows.join("")}</tbody>`,
|
|
33
|
+
"</table>",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Text helpers: escaping, an estimate of how wide a line is, and trimming it to fit. Pure, no DOM, no font files. */
|
|
2
|
+
/** Escapes text for an XML or HTML text node or an attribute value. */
|
|
3
|
+
export declare function esc(text: string | number): string;
|
|
4
|
+
/**
|
|
5
|
+
* About how wide a line is at a font size, in the same units. An estimate from character classes (a system sans):
|
|
6
|
+
* the same on every machine, so that a drawing is the same string everywhere. It can be a few percent off a real font.
|
|
7
|
+
*/
|
|
8
|
+
export declare function textWidth(text: string, size: number): number;
|
|
9
|
+
/** The text cut to fit a width, with an ellipsis where it was cut. */
|
|
10
|
+
export declare function fit(text: string, size: number, width: number): string;
|
|
11
|
+
/** A short name for a small box: the initials of a name of several words ("NYR"), else its first three letters; capitals. */
|
|
12
|
+
export declare function abbreviate(name: string): string;
|
|
13
|
+
/** A small, stable hash of a string as lowercase base 36, for ids that depend on the content and nothing else. */
|
|
14
|
+
export declare function hash(text: string): string;
|
|
15
|
+
/** A number written without float noise: at most two decimals, no trailing zeros. */
|
|
16
|
+
export declare function num(n: number): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Text helpers: escaping, an estimate of how wide a line is, and trimming it to fit. Pure, no DOM, no font files. */
|
|
2
|
+
/** Escapes text for an XML or HTML text node or an attribute value. */
|
|
3
|
+
export function esc(text) {
|
|
4
|
+
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
|
+
}
|
|
6
|
+
const NARROW = new Set("iIjlt1.,:;!|'`()[]/ ");
|
|
7
|
+
const WIDE = new Set("mwMW@%");
|
|
8
|
+
/**
|
|
9
|
+
* About how wide a line is at a font size, in the same units. An estimate from character classes (a system sans):
|
|
10
|
+
* the same on every machine, so that a drawing is the same string everywhere. It can be a few percent off a real font.
|
|
11
|
+
*/
|
|
12
|
+
export function textWidth(text, size) {
|
|
13
|
+
let em = 0;
|
|
14
|
+
for (const ch of text) {
|
|
15
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
16
|
+
if (code >= 0x2e80)
|
|
17
|
+
em += 1;
|
|
18
|
+
else if (NARROW.has(ch))
|
|
19
|
+
em += 0.3;
|
|
20
|
+
else if (WIDE.has(ch))
|
|
21
|
+
em += 0.86;
|
|
22
|
+
else if (ch >= "A" && ch <= "Z")
|
|
23
|
+
em += 0.66;
|
|
24
|
+
else if (ch >= "0" && ch <= "9")
|
|
25
|
+
em += 0.56;
|
|
26
|
+
else
|
|
27
|
+
em += 0.54;
|
|
28
|
+
}
|
|
29
|
+
return em * size;
|
|
30
|
+
}
|
|
31
|
+
/** The text cut to fit a width, with an ellipsis where it was cut. */
|
|
32
|
+
export function fit(text, size, width) {
|
|
33
|
+
if (textWidth(text, size) <= width)
|
|
34
|
+
return text;
|
|
35
|
+
const chars = [...text];
|
|
36
|
+
while (chars.length > 1 && textWidth(`${chars.join("")}…`, size) > width)
|
|
37
|
+
chars.pop();
|
|
38
|
+
return chars.length <= 1 && textWidth(text, size) > width * 2 ? "…" : `${chars.join("").trimEnd()}…`;
|
|
39
|
+
}
|
|
40
|
+
/** A short name for a small box: the initials of a name of several words ("NYR"), else its first three letters; capitals. */
|
|
41
|
+
export function abbreviate(name) {
|
|
42
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
const letters = (word) => [...word].filter((c) => /\p{L}/u.test(c));
|
|
44
|
+
const made = words.length > 1 ? words.map((w) => letters(w)[0] ?? "").join("").slice(0, 3) : letters(words[0] ?? "").slice(0, 3).join("");
|
|
45
|
+
return (made || name.slice(0, 3)).toUpperCase();
|
|
46
|
+
}
|
|
47
|
+
/** A small, stable hash of a string as lowercase base 36, for ids that depend on the content and nothing else. */
|
|
48
|
+
export function hash(text) {
|
|
49
|
+
let h = 0x811c9dc5;
|
|
50
|
+
for (let i = 0; i < text.length; i++) {
|
|
51
|
+
h ^= text.charCodeAt(i);
|
|
52
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
53
|
+
}
|
|
54
|
+
return h.toString(36);
|
|
55
|
+
}
|
|
56
|
+
/** A number written without float noise: at most two decimals, no trailing zeros. */
|
|
57
|
+
export function num(n) {
|
|
58
|
+
if (!Number.isFinite(n))
|
|
59
|
+
throw new Error(`taisen-brackets: cannot draw ${n}`);
|
|
60
|
+
const r = Math.round(n * 100) / 100;
|
|
61
|
+
return Object.is(r, -0) ? "0" : String(r);
|
|
62
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ThemeName } from "./types.ts";
|
|
2
|
+
/** The colours of one look. Each is the fallback of a CSS custom property of the same name: `--tb-ink` and so on. */
|
|
3
|
+
export interface Palette {
|
|
4
|
+
bg: string;
|
|
5
|
+
ink: string;
|
|
6
|
+
muted: string;
|
|
7
|
+
line: string;
|
|
8
|
+
box: string;
|
|
9
|
+
edge: string;
|
|
10
|
+
win: string;
|
|
11
|
+
gold: string;
|
|
12
|
+
plain: string;
|
|
13
|
+
}
|
|
14
|
+
/** The looks. Every colour is the fallback for a custom property, so a page can restyle a drawing with `--tb-ink: …` and nothing else. */
|
|
15
|
+
export declare const THEMES: Readonly<Record<"light" | "dark" | "poster", Palette>>;
|
|
16
|
+
/**
|
|
17
|
+
* The `<style>` text of a drawing. `auto` is the light look and, under `prefers-color-scheme: dark` or a page that says
|
|
18
|
+
* `data-theme="dark"` on `<html>`, the dark one; `light`, `dark` and `poster` are one look always. Every colour can be
|
|
19
|
+
* set from the page with a `--tb-*` custom property.
|
|
20
|
+
*/
|
|
21
|
+
export declare function styleFor(scope: string, theme: Exclude<ThemeName, "none">): string;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The looks. Every colour is the fallback for a custom property, so a page can restyle a drawing with `--tb-ink: …` and nothing else. */
|
|
2
|
+
export const THEMES = Object.freeze({
|
|
3
|
+
light: { bg: "#f4efe4", ink: "#1f2320", muted: "#62665f", line: "#8d9087", box: "#fbf8f1", edge: "#cfc7b3", win: "#b5452c", gold: "#845f06", plain: "#d9d3c2" },
|
|
4
|
+
dark: { bg: "#141614", ink: "#ece8dc", muted: "#9aa095", line: "#6c7168", box: "#1e211d", edge: "#3b403a", win: "#e0705a", gold: "#e0b43b", plain: "#3b403a" },
|
|
5
|
+
poster: { bg: "#0a1130", ink: "#f6f1e3", muted: "#a9b2d3", line: "#d9b24c", box: "#111b47", edge: "#8e7632", win: "#f0c64a", gold: "#f0c64a", plain: "#26336b" },
|
|
6
|
+
});
|
|
7
|
+
const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
|
8
|
+
/** The rules for one palette, each colour read from its custom property with the palette's value as the fallback. */
|
|
9
|
+
function rules(p, poster, withFont) {
|
|
10
|
+
const v = (name) => `var(--tb-${name}, ${p[name]})`;
|
|
11
|
+
const list = [
|
|
12
|
+
[[".tb-bg"], `fill:${v("bg")}`],
|
|
13
|
+
[[".tb-line"], `stroke:${v("line")};stroke-width:${poster ? 1.6 : 1.3};fill:none;stroke-opacity:${poster ? 0.95 : 0.8}`],
|
|
14
|
+
[[".tb-box"], `fill:${v("box")};stroke:${v("edge")};stroke-width:1`],
|
|
15
|
+
[[".tb-final .tb-box"], `stroke:${v("win")};stroke-width:2`],
|
|
16
|
+
[[".tb-name", ".tb-score", ".tb-title"], `fill:${v("ink")}`],
|
|
17
|
+
[[".tb-muted", ".tb-round", ".tb-sub"], `fill:${v("muted")}`],
|
|
18
|
+
[[".tb-won .tb-name", ".tb-won .tb-score"], "font-weight:700"],
|
|
19
|
+
[[".tb-lost .tb-name", ".tb-lost .tb-score", ".tb-lost .tb-mark"], "opacity:.55"],
|
|
20
|
+
[[".tb-ring"], `fill:none;stroke:${v("edge")};stroke-width:1`],
|
|
21
|
+
[[".tb-won .tb-ring"], `stroke:${v("win")};stroke-width:2`],
|
|
22
|
+
[[".tb-plain"], `fill:${v("plain")}`],
|
|
23
|
+
[[".tb-initials"], `fill:${v("ink")}`],
|
|
24
|
+
[[".tb-half"], `fill:${v("ink")}`],
|
|
25
|
+
[[".tb-gold"], `fill:${v("gold")}`],
|
|
26
|
+
[[".tb-champ .tb-ring"], `stroke:${v("gold")};stroke-width:3`],
|
|
27
|
+
[[".tb-champ-label"], `fill:${v("gold")}`],
|
|
28
|
+
[[".tb-halo"], `paint-order:stroke;stroke:${v("bg")};stroke-width:4px;stroke-linejoin:round`],
|
|
29
|
+
];
|
|
30
|
+
if (withFont)
|
|
31
|
+
list.push([["text"], `font-family:var(--tb-font, ${FONT})`]);
|
|
32
|
+
return list;
|
|
33
|
+
}
|
|
34
|
+
const write = (scope, list, before = "") => list.map(([sel, body]) => `${sel.map((x) => `${before}${scope} ${x}`).join(",")}{${body}}`).join("\n");
|
|
35
|
+
/**
|
|
36
|
+
* The `<style>` text of a drawing. `auto` is the light look and, under `prefers-color-scheme: dark` or a page that says
|
|
37
|
+
* `data-theme="dark"` on `<html>`, the dark one; `light`, `dark` and `poster` are one look always. Every colour can be
|
|
38
|
+
* set from the page with a `--tb-*` custom property.
|
|
39
|
+
*/
|
|
40
|
+
export function styleFor(scope, theme) {
|
|
41
|
+
if (theme === "light")
|
|
42
|
+
return write(scope, rules(THEMES.light, false, true));
|
|
43
|
+
if (theme === "dark")
|
|
44
|
+
return write(scope, rules(THEMES.dark, false, true));
|
|
45
|
+
if (theme === "poster")
|
|
46
|
+
return write(scope, rules(THEMES.poster, true, true));
|
|
47
|
+
const dark = rules(THEMES.dark, false, false);
|
|
48
|
+
return [
|
|
49
|
+
write(scope, rules(THEMES.light, false, true)),
|
|
50
|
+
`@media (prefers-color-scheme: dark){\n${write(scope, dark, ':root:not([data-theme="light"]) ')}\n}`,
|
|
51
|
+
write(scope, dark, ':root[data-theme="dark"] '),
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|