@johnmorrisdotca/dougu-sports 0.3.2 → 0.4.1

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/NOTICE.md +1 -0
  3. package/README.md +6 -5
  4. package/dist/emblems/ball/ball.art.d.ts +24 -0
  5. package/dist/emblems/ball/ball.art.js +67 -0
  6. package/dist/emblems/ball/ball.core.d.ts +9 -0
  7. package/dist/emblems/ball/ball.core.js +12 -1
  8. package/dist/emblems/ball/ball.face.d.ts +59 -0
  9. package/dist/emblems/ball/ball.face.js +288 -0
  10. package/dist/emblems/ball/ball.image.d.ts +8 -0
  11. package/dist/emblems/ball/ball.image.js +122 -0
  12. package/dist/emblems/ball/ball.index.d.ts +3 -1
  13. package/dist/emblems/ball/ball.index.js +7 -5
  14. package/dist/emblems/ball/ball.letters.d.ts +32 -0
  15. package/dist/emblems/ball/ball.letters.js +142 -0
  16. package/dist/emblems/ball/ball.object.js +1 -1
  17. package/dist/emblems/ball/ball.puck.d.ts +8 -0
  18. package/dist/emblems/ball/ball.puck.js +19 -12
  19. package/dist/emblems/ball/ball.puck.print.d.ts +76 -0
  20. package/dist/emblems/ball/ball.puck.print.js +46 -0
  21. package/dist/emblems/ball/ball.signature.d.ts +4 -0
  22. package/dist/emblems/ball/ball.signature.js +58 -0
  23. package/dist/emblems/ball/ball.stack.d.ts +10 -0
  24. package/dist/emblems/ball/ball.stack.js +8 -1
  25. package/dist/emblems/emblem.api.d.ts +5 -5
  26. package/dist/emblems/emblem.api.js +27 -8
  27. package/dist/emblems/emblem.core.d.ts +8 -0
  28. package/dist/emblems/emblem.descriptions.js +8 -0
  29. package/dist/emblems/emblem.ids.d.ts +6 -1
  30. package/dist/emblems/emblem.ids.js +4 -1
  31. package/dist/emblems/emblem.registry.js +2 -2
  32. package/dist/emblems-entry.d.ts +3 -2
  33. package/dist/emblems-entry.js +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +1 -0
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +1 -1
@@ -0,0 +1,122 @@
1
+ // An SVG that comes from outside, made safe to put inside a drawing: a strict allow-list, never a deny-list. The text is taken apart into tags; only the shapes, groups, gradients and clip paths survive, with only
2
+ // the attributes a drawing needs and values that cannot carry a script, an address or an entity. Everything else (script, style, text, image, use, foreignObject, filters, links, event handlers, `style`) is dropped
3
+ // with whatever it holds. Ids are renamed under a prefix so two drawings in a page cannot reach each other's gradients. Imports only the core's escape.
4
+ import { escape } from "./ball.core.js";
5
+ const SHAPES = new Set(["g", "defs", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "linearGradient", "radialGradient", "stop", "clipPath"]);
6
+ const ATTRS = new Set([
7
+ "id", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", "fx", "fy", "width", "height", "points", "transform", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit",
8
+ "stroke-dasharray", "stroke-dashoffset", "fill-opacity", "stroke-opacity", "opacity", "fill-rule", "clip-rule", "clip-path", "offset", "stop-color", "stop-opacity", "gradientUnits", "gradientTransform",
9
+ "spreadMethod", "clipPathUnits",
10
+ ]);
11
+ /** The attributes that may hold `url(#id)`. */
12
+ const REFS = new Set(["fill", "stroke", "clip-path"]);
13
+ /** A value is letters, digits and the few marks that colours, numbers, lists and transforms use: no colon (so no address), no `&` (so no entity), no quote, no angle bracket, no backslash. */
14
+ const VALUE = /^[A-Za-z0-9#%.,()\s+\-/_]*$/;
15
+ const MAX_LENGTH = 60000;
16
+ const MAX_NODES = 2000;
17
+ /** One attribute's value made safe, with its ids renamed, or `undefined` to drop it. */
18
+ function safeValue(name, raw, prefix) {
19
+ const value = raw.trim();
20
+ if (value.length > 20000)
21
+ return undefined;
22
+ if (value.includes("url(")) {
23
+ if (!REFS.has(name))
24
+ return undefined;
25
+ const m = /^url\(\s*#([A-Za-z_][\w.-]*)\s*\)(\s+[a-z]+)?$/.exec(value);
26
+ return m === null ? undefined : `url(#${prefix}${m[1]})${m[2] ?? ""}`;
27
+ }
28
+ if (name === "id")
29
+ return /^[A-Za-z_][\w.-]*$/.test(value) ? `${prefix}${value}` : undefined;
30
+ return VALUE.test(value) ? value : undefined;
31
+ }
32
+ /** The declarations of a `style` attribute as name and value pairs (the ones an attribute of the same name may carry are kept by the caller). */
33
+ function styleAttrs(style) {
34
+ const out = [];
35
+ for (const part of style.split(";")) {
36
+ const at = part.indexOf(":");
37
+ if (at > 0)
38
+ out.push([part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim()]);
39
+ }
40
+ return out;
41
+ }
42
+ /** The box of the root's attributes: its viewBox's four numbers, else its width and height, else 100 by 100. */
43
+ function boxOf(attrs) {
44
+ const vb = (attrs["viewBox"] ?? "").trim().split(/[\s,]+/).map(Number);
45
+ if (vb.length === 4 && vb.every(Number.isFinite) && vb[2] > 0 && vb[3] > 0)
46
+ return [vb[0], vb[1], vb[2], vb[3]];
47
+ const w = parseFloat(attrs["width"] ?? ""), h = parseFloat(attrs["height"] ?? "");
48
+ return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? [0, 0, w, h] : [0, 0, 100, 100];
49
+ }
50
+ /** The markup of an SVG made safe, or `undefined` when there is none to be had: not text, too long, no `<svg>` root, nothing drawable left. `prefix` is put in front of every id and every reference to one. */
51
+ export function safeImage(source, prefix) {
52
+ if (typeof source !== "string" || source.length === 0 || source.length > MAX_LENGTH)
53
+ return undefined;
54
+ const cleaned = source.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "").replace(/<\?[\s\S]*?\?>/g, "").replace(/<![^>]*>/g, "");
55
+ const tag = /<(\/?)([A-Za-z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
56
+ let out = "";
57
+ let nodes = 0;
58
+ let root;
59
+ // The depth inside an element being dropped (0 when none is).
60
+ let dropping = 0;
61
+ // The elements written and still open, `#root` for the svg itself.
62
+ const open = [];
63
+ for (let m = tag.exec(cleaned); m !== null; m = tag.exec(cleaned)) {
64
+ const closing = m[1] === "/";
65
+ const name = m[2];
66
+ const selfClosed = m[4] === "/";
67
+ if (dropping > 0) {
68
+ if (closing)
69
+ dropping--;
70
+ else if (!selfClosed)
71
+ dropping++;
72
+ continue;
73
+ }
74
+ if (closing) {
75
+ const top = open.pop();
76
+ if (top !== undefined && top !== "#root")
77
+ out += `</${top}>`;
78
+ continue;
79
+ }
80
+ const attrs = {};
81
+ for (const a of m[3].matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
82
+ attrs[a[1]] = (a[2] ?? a[3]);
83
+ if (root === undefined) {
84
+ if (name !== "svg" || selfClosed)
85
+ return undefined;
86
+ root = attrs;
87
+ open.push("#root");
88
+ continue;
89
+ }
90
+ if (!SHAPES.has(name)) {
91
+ if (!selfClosed)
92
+ dropping = 1;
93
+ continue;
94
+ }
95
+ if (++nodes > MAX_NODES)
96
+ return undefined;
97
+ const pairs = Object.entries(attrs);
98
+ if (attrs["style"] !== undefined)
99
+ pairs.push(...styleAttrs(attrs["style"]));
100
+ let written = "";
101
+ const seen = new Set();
102
+ for (const [key, raw] of pairs) {
103
+ if (!ATTRS.has(key) || seen.has(key))
104
+ continue;
105
+ const v = safeValue(key, raw, prefix);
106
+ if (v === undefined)
107
+ continue;
108
+ seen.add(key);
109
+ written += ` ${key}="${escape(v)}"`;
110
+ }
111
+ out += `<${name}${written}${selfClosed ? "/>" : ">"}`;
112
+ if (!selfClosed)
113
+ open.push(name);
114
+ }
115
+ if (root === undefined || !/<(path|circle|ellipse|rect|line|polyline|polygon)\b/.test(out))
116
+ return undefined;
117
+ // Close what the text left open, so the markup is always balanced.
118
+ for (let top = open.pop(); top !== undefined; top = open.pop())
119
+ if (top !== "#root")
120
+ out += `</${top}>`;
121
+ return { box: boxOf(root), markup: out };
122
+ }
@@ -1,9 +1,10 @@
1
1
  import type { BallFrameOptions, Piece, PieceOptions, PieceSize } from "./ball.core.ts";
2
+ import { PUCKS } from "./ball.puck.print.ts";
2
3
  /** Every ball, in the order a gallery shows them. */
3
4
  export declare const BALLS: readonly Piece[];
4
5
  /** The id of every ball: `"ball:soccer"`, `"ball:cricket-red"`. */
5
6
  export declare const BALL_IDS: readonly string[];
6
- /** A ball by its id, or by its name without the `ball:` (`"soccer"`). An unknown name is an error that lists the ids. */
7
+ /** A ball by its id, or by its name without the `ball:` (`"soccer"`); a printed puck by its id (`"puck:canada"`). An unknown name is an error that lists the ids. */
7
8
  export declare function ballOf(name: string): Piece;
8
9
  /** True when the text is the id of a ball (or a ball's name without `ball:`). */
9
10
  export declare const isBallId: (name: unknown) => name is string;
@@ -45,4 +46,5 @@ export interface LineupOptions extends PieceOptions {
45
46
  * ```
46
47
  */
47
48
  export declare function lineup(names: readonly string[], options?: LineupOptions): string;
49
+ export { PUCKS };
48
50
  export type { BallFrameOptions, Piece, PieceOptions, PieceSize, PieceStyle, PieceTheme } from "./ball.core.ts";
@@ -16,6 +16,7 @@ import { BALL_HANDBALL } from "./ball.handball.js";
16
16
  import { BALL_PADEL } from "./ball.padel.js";
17
17
  import { BALL_PICKLEBALL_OUTDOOR, BALL_PICKLEBALL_INDOOR } from "./ball.pickleball.js";
18
18
  import { BALL_PUCK } from "./ball.puck.js";
19
+ import { PUCKS } from "./ball.puck.print.js";
19
20
  import { BALL_ROUNDNET } from "./ball.roundnet.js";
20
21
  import { BALL_RUGBY } from "./ball.rugby.js";
21
22
  import { BALL_SEPAK_TAKRAW } from "./ball.sepaktakraw.js";
@@ -35,16 +36,16 @@ import { BALL_WATER_POLO } from "./ball.waterpolo.js";
35
36
  export const BALLS = [BALL_SOCCER, BALL_SOCCER_COLOUR, BALL_SOCCER_26, BALL_SOCCER_18, BALL_SOCCER_14, BALL_SOCCER_12, BALL_SOCCER_6, BALL_SOCCER_ORANGE, BALL_SOCCER_26_ORANGE, BALL_SOCCER_18_ORANGE, BALL_SOCCER_14_ORANGE, BALL_SOCCER_12_ORANGE, BALL_SOCCER_6_ORANGE, BALL_SOCCER_YELLOW, BALL_SOCCER_26_YELLOW, BALL_SOCCER_18_YELLOW, BALL_SOCCER_14_YELLOW, BALL_SOCCER_12_YELLOW, BALL_SOCCER_6_YELLOW, BALL_FUTSAL, BALL_BEACH_SOCCER, BALL_TEQBALL, BALL_BASKETBALL, BALL_BASKETBALL_6, BALL_BASKETBALL_3X3, BALL_BASEBALL, BALL_SOFTBALL, BALL_TENNIS, BALL_PADEL, BALL_SLIOTAR, BALL_VOLLEYBALL, BALL_VOLLEYBALL_8, BALL_BEACH_VOLLEYBALL, BALL_CRICKET_RED, BALL_CRICKET_WHITE, BALL_CRICKET_PINK, BALL_FOOTBALL, BALL_FOOTBALL_CFL, BALL_FOOTBALL_IFAF, BALL_RUGBY, BALL_AFL, BALL_GAELIC_FOOTBALL, BALL_NETBALL, BALL_PICKLEBALL_OUTDOOR, BALL_PICKLEBALL_INDOOR, BALL_SQUASH_DOUBLE_YELLOW, BALL_SQUASH_YELLOW, BALL_SQUASH_GREEN, BALL_SQUASH_RED, BALL_SQUASH_BLUE, BALL_RACQUETBALL, BALL_LACROSSE, BALL_FIELD_HOCKEY, BALL_TABLE_TENNIS, BALL_HANDBALL, BALL_WATER_POLO, BALL_GOLF, BALL_PUCK, BALL_CURLING_STONE, BALL_SHOT, BALL_DISCUS, BALL_HAMMER, BALL_SEPAK_TAKRAW, BALL_ROUNDNET, BALL_SHUTTLECOCK_FEATHER, BALL_SHUTTLECOCK_NYLON];
36
37
  /** The id of every ball: `"ball:soccer"`, `"ball:cricket-red"`. */
37
38
  export const BALL_IDS = BALLS.map((b) => b.id);
38
- const TABLE = new Map(BALLS.map((b) => [b.id, b]));
39
- /** A ball by its id, or by its name without the `ball:` (`"soccer"`). An unknown name is an error that lists the ids. */
39
+ const TABLE = new Map([...BALLS, ...PUCKS].map((b) => [b.id, b]));
40
+ /** A ball by its id, or by its name without the `ball:` (`"soccer"`); a printed puck by its id (`"puck:canada"`). An unknown name is an error that lists the ids. */
40
41
  export function ballOf(name) {
41
- const found = TABLE.get(name.startsWith("ball:") ? name : `ball:${name}`);
42
+ const found = TABLE.get(/^(ball|puck):/.test(name) ? name : `ball:${name}`);
42
43
  if (found === undefined)
43
- throw new Error(`Unknown ball "${name}". The ids are ${BALL_IDS.join(", ")}.`);
44
+ throw new Error(`Unknown ball "${name}". The ids are ${BALL_IDS.join(", ")}, and the printed pucks ${PUCKS.map((p) => p.id).join(", ")}.`);
44
45
  return found;
45
46
  }
46
47
  /** True when the text is the id of a ball (or a ball's name without `ball:`). */
47
- export const isBallId = (name) => typeof name === "string" && TABLE.has(name.startsWith("ball:") ? name : `ball:${name}`);
48
+ export const isBallId = (name) => typeof name === "string" && TABLE.has(/^(ball|puck):/.test(name) ? name : `ball:${name}`);
48
49
  /** A ball as a document of SVG: `renderBall("soccer", { size: 64 })`, or in the line style `renderBall("ball:puck", { style: "line" })`. The same arguments give the same string. */
49
50
  export function renderBall(name, options) {
50
51
  return ballOf(name).svg(options);
@@ -103,3 +104,4 @@ export function lineup(names, options) {
103
104
  const title = `Balls at ${trueScale ? "their true relative sizes" : "one size"}: ${pieces.map((p) => p.en).join(", ")}`;
104
105
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${num(width, 2)} ${num(height, 2)}" width="${num(width, 2)}" height="${num(height, 2)}" role="img"${colour === "" ? "" : ` color="${colour}"`}><title>${escape(title)}</title>${auto}${parts.join("")}</svg>`;
105
106
  }
107
+ export { PUCKS };
@@ -0,0 +1,32 @@
1
+ interface Glyph {
2
+ /** The width of the box, 0 for a letter that is a single stroke. */
3
+ w: number;
4
+ /** The centre line: absolute `M L C Z` commands. */
5
+ d: string;
6
+ }
7
+ /** The glyph of a character (a lower-case letter is its capital, an accented one its plain one; the bullet and the middle dot are one dot), or `undefined`. */
8
+ export declare function glyphOf(char: string): Glyph | undefined;
9
+ /** How a line is set. `size` is the height of a capital in the face's units; the stroke is `weight` of it. */
10
+ export interface Lettering {
11
+ size: number;
12
+ /** The stroke as a fraction of the height: 0.12 is regular, 0.2 bold, 0.27 black. */
13
+ weight: number;
14
+ /** 1 is the glyphs' own width; the wide print of CANADA is 1.3 or more. */
15
+ wide?: number;
16
+ /** The lean: how far the top of a letter is pushed to the right, as a fraction of the height (0.12 is a sports slant). */
17
+ slant?: number;
18
+ /** Extra space between letters, as a fraction of the height. */
19
+ track?: number;
20
+ }
21
+ /** The width a text takes, in the face's units, with the stroke's own thickness included. */
22
+ export declare function textWidth(text: string, o: Lettering): number;
23
+ /** The size at which a text is as wide as `width` at most and as tall as `height` at most. */
24
+ export declare function fitSize(text: string, o: Omit<Lettering, "size">, width: number, height: number): number;
25
+ /** A line of text on a straight line, centred on (`x`, `y`), as one path: draw it with a stroke of `weight * size`. */
26
+ export declare function linePath(text: string, o: Lettering, x: number, y: number): string;
27
+ /**
28
+ * A line of text set on an arc of the circle of radius `r` (the middle of the letters), centred on the angle `centre` in degrees clockwise from the top. With `inside` false the letters stand outward and read
29
+ * clockwise (the top of the ring); with `inside` true they stand inward and read left to right (the bottom of the ring, `centre` 180). One path.
30
+ */
31
+ export declare function arcPath(text: string, o: Lettering, r: number, centre: number, inside?: boolean): string;
32
+ export {};
@@ -0,0 +1,142 @@
1
+ // Vector lettering for the printed pucks: every letter and numeral is a centre line in a box 90 units tall, and the stroke width makes it bold. No font file and no system font is used, so the print
2
+ // looks the same everywhere and is a few hundred bytes of path. The strokes are adapted from the block letters of senshu-jerseys (the same author, the same MIT licence); here they are drawn wide, heavy and
3
+ // leaning a little forward, so a puck's CANADA is a sports print of its own. A line of text comes out as one path in the face's own units (the stroke width is the caller's), straight or set on an arc.
4
+ // A character with no glyph is left out. Imports only the core's number writer.
5
+ import { num } from "./ball.core.js";
6
+ const GLYPHS = {
7
+ "0": { w: 48, d: "M24 0C8 0 0 14 0 45C0 76 8 90 24 90C40 90 48 76 48 45C48 14 40 0 24 0Z" },
8
+ "1": { w: 28, d: "M0 24L28 0L28 90" },
9
+ "2": { w: 48, d: "M0 22C0 8 10 0 24 0C38 0 48 8 48 24C48 38 40 46 28 56L0 90L48 90" },
10
+ "3": { w: 48, d: "M2 0L46 0L24 34C40 34 48 46 48 62C48 80 38 90 24 90C10 90 2 82 0 68" },
11
+ "4": { w: 50, d: "M36 90L36 0L0 62L50 62" },
12
+ "5": { w: 48, d: "M46 0L6 0L2 40C10 34 18 32 26 32C40 32 48 46 48 62C48 80 38 90 24 90C10 90 2 82 0 70" },
13
+ "6": { w: 48, d: "M44 6C38 2 32 0 24 0C8 0 0 16 0 46C0 76 8 90 24 90C40 90 48 78 48 62C48 46 40 36 24 36C12 36 2 44 0 56" },
14
+ "7": { w: 48, d: "M0 0L48 0L16 90" },
15
+ "8": { w: 46, d: "M23 0C10 0 4 8 4 20C4 32 12 40 23 43C36 46 46 52 46 66C46 82 38 90 23 90C8 90 0 82 0 66C0 52 10 46 23 43C34 40 42 32 42 20C42 8 36 0 23 0Z" },
16
+ "9": { w: 48, d: "M4 84C10 88 16 90 24 90C40 90 48 74 48 44C48 14 40 0 24 0C8 0 0 12 0 28C0 44 8 54 24 54C36 54 46 46 48 34" },
17
+ A: { w: 50, d: "M0 90L25 0L50 90M10 60L40 60" },
18
+ B: { w: 46, d: "M0 90L0 0L26 0C40 0 44 10 44 21C44 34 36 42 26 43L0 43M26 43C40 44 46 52 46 66C46 82 40 90 26 90L0 90" },
19
+ C: { w: 46, d: "M46 24C44 8 36 0 24 0C8 0 0 14 0 45C0 76 8 90 24 90C36 90 44 82 46 66" },
20
+ D: { w: 46, d: "M0 0L0 90L20 90C40 90 46 76 46 45C46 14 40 0 20 0Z" },
21
+ E: { w: 42, d: "M42 0L0 0L0 90L42 90M0 44L34 44" },
22
+ F: { w: 40, d: "M40 0L0 0L0 90M0 44L32 44" },
23
+ G: { w: 46, d: "M46 24C44 8 36 0 24 0C8 0 0 14 0 45C0 76 8 90 24 90C40 90 46 78 46 58L46 50L26 50" },
24
+ H: { w: 46, d: "M0 0L0 90M46 0L46 90M0 44L46 44" },
25
+ I: { w: 0, d: "M0 0L0 90" },
26
+ J: { w: 38, d: "M38 0L38 64C38 82 30 90 20 90C8 90 0 82 0 66" },
27
+ K: { w: 46, d: "M0 0L0 90M46 0L2 50M14 36L46 90" },
28
+ L: { w: 38, d: "M0 0L0 90L38 90" },
29
+ M: { w: 58, d: "M0 90L0 0L29 62L58 0L58 90" },
30
+ N: { w: 46, d: "M0 90L0 0L46 90L46 0" },
31
+ O: { w: 48, d: "M24 0C8 0 0 14 0 45C0 76 8 90 24 90C40 90 48 76 48 45C48 14 40 0 24 0Z" },
32
+ P: { w: 46, d: "M0 90L0 0L28 0C42 0 46 10 46 24C46 38 40 48 26 48L0 48" },
33
+ Q: { w: 48, d: "M24 0C8 0 0 14 0 45C0 76 8 90 24 90C40 90 48 76 48 45C48 14 40 0 24 0ZM30 68L50 94" },
34
+ R: { w: 46, d: "M0 90L0 0L28 0C42 0 46 10 46 22C46 36 40 46 26 47L0 47M24 47L46 90" },
35
+ S: { w: 46, d: "M44 22C42 8 34 0 22 0C8 0 2 10 2 22C2 36 12 40 24 45C36 50 46 56 46 68C46 82 38 90 24 90C10 90 2 82 0 66" },
36
+ T: { w: 46, d: "M0 0L46 0M23 0L23 90" },
37
+ U: { w: 46, d: "M0 0L0 62C0 82 8 90 23 90C38 90 46 82 46 62L46 0" },
38
+ V: { w: 48, d: "M0 0L24 90L48 0" },
39
+ W: { w: 60, d: "M0 0L15 90L30 28L45 90L60 0" },
40
+ X: { w: 48, d: "M0 0L48 90M48 0L0 90" },
41
+ Y: { w: 48, d: "M0 0L24 46L48 0M24 46L24 90" },
42
+ Z: { w: 46, d: "M0 0L46 0L0 90L46 90" },
43
+ "-": { w: 24, d: "M0 45L24 45" },
44
+ ".": { w: 0, d: "M0 80L0 90" },
45
+ ",": { w: 0, d: "M0 80L0 92" },
46
+ "'": { w: 0, d: "M0 0L0 22" },
47
+ "/": { w: 26, d: "M0 90L26 0" },
48
+ ":": { w: 0, d: "M0 22L0 32M0 70L0 80" },
49
+ "&": { w: 52, d: "M52 90L10 34C2 22 6 0 24 0C42 0 44 20 36 30L8 60C-2 76 6 90 24 90C34 90 44 80 50 60" },
50
+ "·": { w: 0, d: "M0 42L0 48" },
51
+ " ": { w: 22, d: "" },
52
+ };
53
+ /** The glyph of a character (a lower-case letter is its capital, an accented one its plain one; the bullet and the middle dot are one dot), or `undefined`. */
54
+ export function glyphOf(char) {
55
+ const plain = (char === "•" || char === "·" ? "·" : char.normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase());
56
+ return Object.hasOwn(GLYPHS, plain) ? GLYPHS[plain] : undefined;
57
+ }
58
+ const GLYPH_H = 90;
59
+ /** The characters of a text that have a glyph, as glyphs (an unknown character is left out). */
60
+ const glyphsOf = (text) => [...text].map(glyphOf).filter((g) => g !== undefined);
61
+ /** The pen positions of a text in glyph units: the left edge of each glyph's box and the total width, from the first glyph's left edge to the last one's right edge. */
62
+ function setting(glyphs, o) {
63
+ const wide = o.wide ?? 1;
64
+ const stroke = o.weight * GLYPH_H;
65
+ const gap = Math.max(5, stroke * 0.5) + (o.track ?? 0) * GLYPH_H;
66
+ const at = [];
67
+ let x = 0;
68
+ for (const g of glyphs) {
69
+ at.push(x);
70
+ x += g.w * wide + stroke + gap;
71
+ }
72
+ const total = Math.max(0, x - stroke - gap);
73
+ return { at, total, stroke };
74
+ }
75
+ /** The width a text takes, in the face's units, with the stroke's own thickness included. */
76
+ export function textWidth(text, o) {
77
+ const glyphs = glyphsOf(text);
78
+ const s = setting(glyphs, o);
79
+ return ((s.total + s.stroke) * o.size) / GLYPH_H;
80
+ }
81
+ /** The size at which a text is as wide as `width` at most and as tall as `height` at most. */
82
+ export function fitSize(text, o, width, height) {
83
+ const w = textWidth(text, { ...o, size: 1 });
84
+ return Math.min(height, w === 0 ? height : width / w);
85
+ }
86
+ /** A glyph's path with every point run through `map`. The glyph tables use absolute `M L C Z` only, so each pair of numbers is a point. */
87
+ function mapPath(d, map) {
88
+ let out = "";
89
+ for (const part of d.match(/[MLCZ][^MLCZ]*/g) ?? []) {
90
+ const nums = (part.slice(1).match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
91
+ const points = [];
92
+ for (let i = 0; i + 1 < nums.length; i += 2) {
93
+ const q = map([nums[i], nums[i + 1]]);
94
+ points.push(`${num(q[0], 3)} ${num(q[1], 3)}`);
95
+ }
96
+ out += part[0] + points.join(" ");
97
+ }
98
+ return out;
99
+ }
100
+ /** A point of a glyph made wide and leaning, in glyph units with the origin at the top left of its box. */
101
+ const shaped = (o) => (p) => [p[0] * (o.wide ?? 1) + (o.slant ?? 0) * (GLYPH_H - p[1]), p[1]];
102
+ /** A line of text on a straight line, centred on (`x`, `y`), as one path: draw it with a stroke of `weight * size`. */
103
+ export function linePath(text, o, x, y) {
104
+ const glyphs = glyphsOf(text);
105
+ const s = setting(glyphs, o);
106
+ const k = o.size / GLYPH_H;
107
+ const left = x - (s.total * k) / 2;
108
+ const top = y - o.size / 2;
109
+ const shape = shaped(o);
110
+ return glyphs.map((g, i) => mapPath(g.d, (p) => {
111
+ const q = shape(p);
112
+ return [left + (s.at[i] + q[0]) * k, top + q[1] * k];
113
+ })).join("");
114
+ }
115
+ /**
116
+ * A line of text set on an arc of the circle of radius `r` (the middle of the letters), centred on the angle `centre` in degrees clockwise from the top. With `inside` false the letters stand outward and read
117
+ * clockwise (the top of the ring); with `inside` true they stand inward and read left to right (the bottom of the ring, `centre` 180). One path.
118
+ */
119
+ export function arcPath(text, o, r, centre, inside = false) {
120
+ const glyphs = glyphsOf(text);
121
+ const s = setting(glyphs, o);
122
+ const k = o.size / GLYPH_H;
123
+ const shape = shaped(o);
124
+ const base = inside ? r + o.size / 2 : r - o.size / 2;
125
+ const mid = (centre * Math.PI) / 180;
126
+ return glyphs.map((g, i) => {
127
+ const half = (g.w * (o.wide ?? 1)) / 2;
128
+ const along = (s.at[i] + half - s.total / 2) * k;
129
+ const a = inside ? mid - along / r : mid + along / r;
130
+ const sin = Math.sin(a), cos = Math.cos(a);
131
+ const ox = base * sin, oy = -base * cos;
132
+ // The glyph's own axes: along the line of reading, and down the glyph (toward the centre on the top, outward on the bottom).
133
+ const [ax, ay] = inside ? [-cos, -sin] : [cos, sin];
134
+ const [dx, dy] = inside ? [sin, -cos] : [-sin, cos];
135
+ return mapPath(g.d, (p) => {
136
+ const q = shape(p);
137
+ const lx = (q[0] - half * 1) * k;
138
+ const ly = (q[1] - GLYPH_H) * k;
139
+ return [ox + lx * ax + ly * dx, oy + lx * ay + ly * dy];
140
+ });
141
+ }).join("");
142
+ }
@@ -7,7 +7,7 @@ export function defineObject(def) {
7
7
  const render = (o, turning) => {
8
8
  const opts = resolveOptions(o);
9
9
  const rot = rotation(radians(def.view.yaw + finite(o?.yaw), 0), radians(def.view.pitch + finite(o?.pitch), 0), radians(def.view.roll + finite(o?.roll), 0));
10
- const id = `k${hashOf(`${def.id}|${opts.style}|${opts.tint ?? ""}|${opts.accent ?? ""}|${rot.map((r) => r.map((x) => x.toFixed(2)).join()).join()}`)}`;
10
+ const id = `k${hashOf(`${def.id}|${opts.style}|${opts.tint ?? ""}|${opts.accent ?? ""}${opts.faceKey === "" ? "" : `|f${opts.faceKey}`}|${rot.map((r) => r.map((x) => x.toFixed(2)).join()).join()}`)}`;
11
11
  const at = (p) => {
12
12
  const sh = def.shift ?? [0, 0, 0];
13
13
  const k = def.scale ?? 1;
@@ -1,3 +1,11 @@
1
+ import { type StackSpec } from "./ball.stack.ts";
2
+ import { type PieceSize } from "./ball.core.ts";
3
+ /** Half the puck's thickness in units of its radius: 12.7 mm over 38.1 mm, one third. */
4
+ export declare const PUCK_H: number;
5
+ /** The puck's size and the rule it comes from: the printed pucks are the same puck. */
6
+ export declare const PUCK_SIZE: PieceSize;
7
+ /** The plain puck's drawing: black rubber, a ridged edge, and the ring a moulded top face has. A printed puck is this drawing with its print laid over the ring's place (ball.puck.print.ts). */
8
+ export declare const puckSpec: (extra?: Partial<StackSpec>) => StackSpec;
1
9
  export declare const BALL_PUCK: import("./ball.core.ts").Piece<{
2
10
  slices: number;
3
11
  } & {
@@ -1,22 +1,29 @@
1
1
  // Ice hockey puck: vulcanised black rubber, 76.2 mm across and 25.4 mm thick (so 1 : 3), with a ridged edge. Drawn as a cylinder in true 3D, so it turns. The stack's y is in units of the radius, so the half thickness is 12.7 / 38.1, one third.
2
2
  import { drawStack } from "./ball.stack.js";
3
+ import {} from "./ball.core.js";
3
4
  import { defineObject } from "./ball.object.js";
4
- const H = 25.4 / 76.2;
5
+ /** Half the puck's thickness in units of its radius: 12.7 mm over 38.1 mm, one third. */
6
+ export const PUCK_H = 25.4 / 76.2;
7
+ /** The puck's size and the rule it comes from: the printed pucks are the same puck. */
8
+ export const PUCK_SIZE = { extentMm: 76, diameterMm: 76, thicknessMm: 25, massG: [156, 170], rule: "IIHF Official Rule Book, Rule 13 Puck, 13.1 Dimensions (the puck is Rule 13, not Rule 1)", note: "IIHF figures, rounded as the rule rounds them (the NHL puck is 3 in by 1 in). Colour is not stated in the rule." };
9
+ /** The plain puck's drawing: black rubber, a ridged edge, and the ring a moulded top face has. A printed puck is this drawing with its print laid over the ring's place (ball.puck.print.ts). */
10
+ export const puckSpec = (extra) => ({
11
+ slices: [{ y0: -PUCK_H, y1: PUCK_H, r: 1, colour: "#26282d", outline: true }],
12
+ top: "#2e3036",
13
+ bottom: "#2e3036",
14
+ gloss: 0.5,
15
+ shade: "#000",
16
+ grain: { f: 1.6, a: 0.25 },
17
+ grooves: { count: 30, slices: [0], colour: "#000", opacity: 0.5 },
18
+ topRing: { r: 0.9, colour: "#4a4d56" },
19
+ ...extra,
20
+ });
5
21
  export const BALL_PUCK = /* @__PURE__ */ defineObject({
6
22
  id: "ball:puck",
7
23
  en: "Hockey puck",
8
24
  ja: "アイスホッケーのパック",
9
- size: { extentMm: 76, diameterMm: 76, thicknessMm: 25, massG: [156, 170], rule: "IIHF Official Rule Book, Rule 13 Puck, 13.1 Dimensions (the puck is Rule 13, not Rule 1)", note: "IIHF figures, rounded as the rule rounds them (the NHL puck is 3 in by 1 in). Colour is not stated in the rule." },
25
+ size: PUCK_SIZE,
10
26
  view: { yaw: 0, pitch: 36, roll: -8 },
11
27
  model: { slices: 1 },
12
- draw: (c) => drawStack(c, {
13
- slices: [{ y0: -H, y1: H, r: 1, colour: "#26282d", outline: true }],
14
- top: "#2e3036",
15
- bottom: "#2e3036",
16
- gloss: 0.5,
17
- shade: "#000",
18
- grain: { f: 1.6, a: 0.25 },
19
- grooves: { count: 30, slices: [0], colour: "#000", opacity: 0.5 },
20
- topRing: { r: 0.9, colour: "#4a4d56" },
21
- }),
28
+ draw: (c) => drawStack(c, puckSpec()),
22
29
  });
@@ -0,0 +1,76 @@
1
+ import { type Piece } from "./ball.core.ts";
2
+ import { type PuckDesign } from "./ball.face.ts";
3
+ export declare const PUCK_CANADA: Piece<{
4
+ slices: number;
5
+ design: PuckDesign;
6
+ view: {
7
+ yaw: number;
8
+ pitch: number;
9
+ roll: number;
10
+ };
11
+ }>;
12
+ export declare const PUCK_CANADA_RED: Piece<{
13
+ slices: number;
14
+ design: PuckDesign;
15
+ view: {
16
+ yaw: number;
17
+ pitch: number;
18
+ roll: number;
19
+ };
20
+ }>;
21
+ export declare const PUCK_CANADA_WHITE: Piece<{
22
+ slices: number;
23
+ design: PuckDesign;
24
+ view: {
25
+ yaw: number;
26
+ pitch: number;
27
+ roll: number;
28
+ };
29
+ }>;
30
+ export declare const PUCK_USA: Piece<{
31
+ slices: number;
32
+ design: PuckDesign;
33
+ view: {
34
+ yaw: number;
35
+ pitch: number;
36
+ roll: number;
37
+ };
38
+ }>;
39
+ export declare const PUCK_USA_NAVY: Piece<{
40
+ slices: number;
41
+ design: PuckDesign;
42
+ view: {
43
+ yaw: number;
44
+ pitch: number;
45
+ roll: number;
46
+ };
47
+ }>;
48
+ export declare const PUCK_PLAYER: Piece<{
49
+ slices: number;
50
+ design: PuckDesign;
51
+ view: {
52
+ yaw: number;
53
+ pitch: number;
54
+ roll: number;
55
+ };
56
+ }>;
57
+ export declare const PUCK_SIGNED: Piece<{
58
+ slices: number;
59
+ design: PuckDesign;
60
+ view: {
61
+ yaw: number;
62
+ pitch: number;
63
+ roll: number;
64
+ };
65
+ }>;
66
+ export declare const PUCK_SOUVENIR: Piece<{
67
+ slices: number;
68
+ design: PuckDesign;
69
+ view: {
70
+ yaw: number;
71
+ pitch: number;
72
+ roll: number;
73
+ };
74
+ }>;
75
+ /** Every printed puck, in the order a gallery shows them. */
76
+ export declare const PUCKS: readonly Piece[];
@@ -0,0 +1,46 @@
1
+ // The printed pucks: the same black puck as `ball:puck` (76.2 mm across, 25.4 mm thick, the ridged edge), with a print on its top face. The print is drawn flat (ball.face.ts) and laid on the top by the
2
+ // puck's own rotation, so it foreshortens as the puck tips, turns with it, and is gone when the top is turned away (the bottom is plain rubber). The grip on the edge shows as the puck turns. All our own drawing:
3
+ // our own leaf and flags, our own lettering, a made-up number and name, a made-up signature; no team's or league's mark. Each puck is an id of its own with a face to start from, and any `face` option replaces it.
4
+ import {} from "./ball.core.js";
5
+ import { faceMarkup } from "./ball.face.js";
6
+ import { PUCK_H, PUCK_SIZE, puckSpec } from "./ball.puck.js";
7
+ import { defineObject } from "./ball.object.js";
8
+ import { drawStack } from "./ball.stack.js";
9
+ import { num } from "./ball.core.js";
10
+ /** A printed puck: a plain puck with the face laid over its top. */
11
+ function definePuck(def) {
12
+ return defineObject({
13
+ id: def.id,
14
+ en: def.en,
15
+ ja: def.ja,
16
+ size: PUCK_SIZE,
17
+ view: { yaw: 0, pitch: 50, roll: -6 },
18
+ model: { slices: 1, design: def.design },
19
+ draw: (c) => drawStack(c, puckSpec({
20
+ topRing: undefined,
21
+ overlay: (cx, top) => {
22
+ if (top.nz < 0.02)
23
+ return { defs: "", body: "" };
24
+ // The top face's plane in the box: its centre, and where a step of 1 along the puck's x and z axes goes (the face's right, and its down).
25
+ const o = cx.at([0, PUCK_H, 0]), ex = cx.at([1, PUCK_H, 0]), ez = cx.at([0, PUCK_H, 1]);
26
+ const m = [ex[0] - o[0], ex[1] - o[1], ez[0] - o[0], ez[1] - o[1], o[0], o[1]].map((v) => num(v, 4)).join(" ");
27
+ const face = faceMarkup(cx.opts.face, def.design, def.own, `${cx.id}p`, cx.opts.flags, cx.opts.theme === "light" || cx.opts.theme === "dark" ? cx.opts.theme : "inherit");
28
+ // A light from the upper left and a falling-off at the far edge, over the print, in the box's own space so the light stays put while the puck turns.
29
+ const gid = `${cx.id}fl`;
30
+ const defs = `${face.defs}<radialGradient id="${gid}" cx=".36" cy=".3" r=".85"><stop offset="0" stop-color="#fff" stop-opacity=".34"/><stop offset=".4" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".42"/></radialGradient>`;
31
+ const body = `<g transform="matrix(${m})">${face.body}</g><path d="${top.ellipse(0.985)}" fill="url(#${gid})" stroke="#000" stroke-opacity=".55" stroke-width="${num(Math.max(0.3, cx.minWidth * 0.35), 2)}"/>`;
32
+ return { defs, body };
33
+ },
34
+ })),
35
+ });
36
+ }
37
+ export const PUCK_CANADA = /* @__PURE__ */ definePuck({ id: "puck:canada", en: "Canada flag puck", ja: "カナダ国旗のパック", design: "canada" });
38
+ export const PUCK_CANADA_RED = /* @__PURE__ */ definePuck({ id: "puck:canada-red", en: "Canada red puck", ja: "カナダ赤地のパック", design: "canada-red" });
39
+ export const PUCK_CANADA_WHITE = /* @__PURE__ */ definePuck({ id: "puck:canada-white", en: "Canada white puck", ja: "カナダ白地のパック", design: "canada-white" });
40
+ export const PUCK_USA = /* @__PURE__ */ definePuck({ id: "puck:usa", en: "USA stripes puck", ja: "アメリカ星条旗のパック", design: "usa" });
41
+ export const PUCK_USA_NAVY = /* @__PURE__ */ definePuck({ id: "puck:usa-navy", en: "USA navy puck", ja: "アメリカ紺地のパック", design: "usa-navy" });
42
+ export const PUCK_PLAYER = /* @__PURE__ */ definePuck({ id: "puck:player", en: "Player number puck", ja: "選手名と背番号のパック", design: "player" });
43
+ export const PUCK_SIGNED = /* @__PURE__ */ definePuck({ id: "puck:signed", en: "Signed puck", ja: "サイン入りパック", design: "usa-navy", own: { signature: 7, text: "" } });
44
+ export const PUCK_SOUVENIR = /* @__PURE__ */ definePuck({ id: "puck:souvenir", en: "Souvenir puck", ja: "記念パック", design: "souvenir" });
45
+ /** Every printed puck, in the order a gallery shows them. */
46
+ export const PUCKS = [PUCK_CANADA, PUCK_CANADA_RED, PUCK_CANADA_WHITE, PUCK_USA, PUCK_USA_NAVY, PUCK_PLAYER, PUCK_SIGNED, PUCK_SOUVENIR];
@@ -0,0 +1,4 @@
1
+ /** A small seeded random generator (mulberry32) over any seed: a number, or text hashed first. */
2
+ export declare function seeded(seed: number | string): () => number;
3
+ /** The path of a signature for a seed: a first name, a surname and an underline, in a box about 1 wide, centred on the origin. */
4
+ export declare function signaturePath(seed: number | string): string;
@@ -0,0 +1,58 @@
1
+ // A made-up signature: a squiggle drawn by a seeded pen, so the same seed always gives the same stroke and no seed is anyone's real hand. It is a first name and a surname, each a tall capital with a loop and a run
2
+ // of humps, joined by a smooth curve, and a flourish under the whole. The path lies in a box 1 wide and about 0.4 tall about the origin; the face scales, turns and inks it. Imports nothing.
3
+ /** A small seeded random generator (mulberry32) over any seed: a number, or text hashed first. */
4
+ export function seeded(seed) {
5
+ let h = 2166136261;
6
+ for (const ch of String(seed))
7
+ h = Math.imul(h ^ ch.codePointAt(0), 16777619);
8
+ let a = h >>> 0;
9
+ return () => {
10
+ a = (a + 0x6d2b79f5) >>> 0;
11
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
12
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
13
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
14
+ };
15
+ }
16
+ const f = (v) => (Math.round(v * 1000) / 1000 + 0).toString();
17
+ /** A smooth curve through the points (Catmull-Rom turned into cubic Beziers). */
18
+ function smooth(pts) {
19
+ let d = `M${f(pts[0][0])} ${f(pts[0][1])}`;
20
+ for (let i = 0; i < pts.length - 1; i++) {
21
+ const p0 = pts[Math.max(0, i - 1)], p1 = pts[i], p2 = pts[i + 1], p3 = pts[Math.min(pts.length - 1, i + 2)];
22
+ d += `C${f(p1[0] + (p2[0] - p0[0]) / 6)} ${f(p1[1] + (p2[1] - p0[1]) / 6)} ${f(p2[0] - (p3[0] - p1[0]) / 6)} ${f(p2[1] - (p3[1] - p1[1]) / 6)} ${f(p2[0])} ${f(p2[1])}`;
23
+ }
24
+ return d;
25
+ }
26
+ /** One word of a signature as a cursive run: a pen that goes round in loops while it moves right, so the strokes are the looped humps of joined writing. The first loop (the capital) is the tallest. */
27
+ function word(rand, x0, width, humps) {
28
+ const pts = [];
29
+ const per = 9;
30
+ const steps = (humps + 1) * per;
31
+ let env = 1;
32
+ for (let i = 0; i <= steps; i++) {
33
+ const u = i / steps;
34
+ const phase = (i / per) * Math.PI * 2;
35
+ const hump = Math.floor(i / per);
36
+ if (i % per === 0)
37
+ env = hump === 0 ? 1.7 : 0.35 + rand() * 0.75;
38
+ // The loop: x is pushed back as y rises, so the pen doubles back on itself at the top.
39
+ const rise = (1 - Math.cos(phase)) / 2;
40
+ const x = x0 + width * u - Math.sin(phase) * 0.045 * env;
41
+ const y = -rise * 0.17 * env + 0.03 * Math.sin(phase * 0.5);
42
+ pts.push([x, y]);
43
+ }
44
+ // A short upward tail.
45
+ const last = pts[pts.length - 1];
46
+ pts.push([last[0] + width * 0.12, last[1] - 0.06 - rand() * 0.04]);
47
+ return pts;
48
+ }
49
+ /** The path of a signature for a seed: a first name, a surname and an underline, in a box about 1 wide, centred on the origin. */
50
+ export function signaturePath(seed) {
51
+ const rand = seeded(seed);
52
+ const first = word(rand, -0.5, 0.28, 2 + Math.floor(rand() * 2));
53
+ const second = word(rand, -0.12, 0.5, 3 + Math.floor(rand() * 3));
54
+ const end = second[second.length - 1];
55
+ const under = [[end[0] - 0.05, end[1] + 0.13 + rand() * 0.03], [-0.05, 0.15 + rand() * 0.03], [-0.38, 0.12 + rand() * 0.04]];
56
+ const lift = (list) => list.map(([x, y]) => [x, y - 0.02]);
57
+ return smooth(lift(first)) + smooth(lift(second)) + smooth(lift([end, ...under]));
58
+ }
@@ -41,6 +41,16 @@ export interface StackSpec {
41
41
  cy: number;
42
42
  nz: number;
43
43
  }) => string;
44
+ /** Drawn over everything, grain included, when the top face is toward you (a print): its definitions and its body, given the top face's ellipse as a path of radius `r` (a fraction of the top's). */
45
+ overlay?: (c: ObjectContext, top: {
46
+ cx: number;
47
+ cy: number;
48
+ nz: number;
49
+ ellipse: (r: number) => string;
50
+ }) => {
51
+ defs: string;
52
+ body: string;
53
+ };
44
54
  }
45
55
  export declare function drawStack(c: ObjectContext, spec: StackSpec): {
46
56
  defs: string;
@@ -123,5 +123,12 @@ export function drawStack(c, spec) {
123
123
  defs += grainDef(c.id, spec.grain.f, spec.grain.a);
124
124
  if (line)
125
125
  return { defs: "", body: `<g fill="none" stroke="currentColor" stroke-width="${num(Math.max(2.4, c.minWidth * 1.4), 2)}" stroke-linejoin="round" stroke-linecap="round">${body}</g>` };
126
- return { defs, body: grain ? `<g filter="url(#${c.id}n)">${body}</g>` : body };
126
+ let printed = "";
127
+ if (spec.overlay !== undefined && topVisible) {
128
+ const top = slices.at(-1);
129
+ const over = spec.overlay(c, { cx: tx, cy: ty, nz, ellipse: (r) => ellipsePath(tx, ty, 46 * radius(top, 1) * r, 46 * radius(top, 1) * r * Math.abs(nz), perpDeg) });
130
+ defs += over.defs;
131
+ printed = over.body;
132
+ }
133
+ return { defs, body: (grain ? `<g filter="url(#${c.id}n)">${body}</g>` : body) + printed };
127
134
  }