@arraypress/covers 0.0.0-stage → 1.0.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +29 -0
  3. package/README.md +561 -2
  4. package/fonts/OFL.txt +93 -0
  5. package/fonts/inter-latin-400.woff +0 -0
  6. package/fonts/inter-latin-500.woff +0 -0
  7. package/fonts/inter-latin-600.woff +0 -0
  8. package/fonts/inter-latin-700.woff +0 -0
  9. package/package.json +96 -4
  10. package/recipes/aurora.json +132 -0
  11. package/recipes/bauhaus.json +113 -0
  12. package/recipes/bigType.json +103 -0
  13. package/recipes/contour.json +109 -0
  14. package/recipes/cutout.json +191 -0
  15. package/recipes/diagram.json +92 -0
  16. package/recipes/dither.json +170 -0
  17. package/recipes/duotone.json +75 -0
  18. package/recipes/gridTitle.json +136 -0
  19. package/recipes/iconTile.json +214 -0
  20. package/recipes/issue.json +221 -0
  21. package/recipes/mark.json +170 -0
  22. package/recipes/photo.json +156 -0
  23. package/recipes/riso.json +387 -0
  24. package/recipes/stripes.json +206 -0
  25. package/recipes/waveform.json +154 -0
  26. package/schema/recipe.schema.json +2292 -0
  27. package/src/astro.d.ts +31 -0
  28. package/src/astro.js +86 -0
  29. package/src/color.js +246 -0
  30. package/src/diagram.js +248 -0
  31. package/src/extract.js +90 -0
  32. package/src/flatten.js +234 -0
  33. package/src/fonts.js +200 -0
  34. package/src/geometry.js +187 -0
  35. package/src/grid.js +41 -0
  36. package/src/handmade.js +253 -0
  37. package/src/icons.js +157 -0
  38. package/src/index.d.ts +897 -0
  39. package/src/index.js +19 -0
  40. package/src/json-recipe.js +458 -0
  41. package/src/layer-options.js +37 -0
  42. package/src/layers.js +972 -0
  43. package/src/palette.js +185 -0
  44. package/src/png.js +77 -0
  45. package/src/print.js +658 -0
  46. package/src/recipes.js +78 -0
  47. package/src/render.js +462 -0
  48. package/src/rng.js +95 -0
  49. package/src/svg.js +69 -0
  50. package/src/text.js +230 -0
package/src/astro.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { CoverOptions, Cover, MIME } from './index.js';
2
+
3
+ export interface CoverEndpointOptions<T> {
4
+ /** The entries to make covers for, e.g. `() => getCollection('posts')`. */
5
+ getEntries: () => T[] | Promise<T[]>;
6
+ /** An entry's cover. */
7
+ cover: (entry: T) => CoverOptions | Cover | Promise<CoverOptions | Cover>;
8
+ /** An entry's slug. Default `entry.id ?? entry.slug`. */
9
+ slug?: (entry: T) => string;
10
+ /** The route param, matching the file name (`[slug].webp.ts`). Default `'slug'`. */
11
+ param?: string;
12
+ /** Default `'webp'`; match the file name's extension. */
13
+ format?: keyof typeof MIME;
14
+ quality?: number;
15
+ /** Content-hash cache: `true` (default) for `node_modules/.cache/covers`, a directory, or `false`. */
16
+ cache?: boolean | string;
17
+ }
18
+
19
+ export interface CoverRouteContext<T> {
20
+ params: Record<string, string | undefined>;
21
+ props?: { entry?: T };
22
+ }
23
+
24
+ /** `getStaticPaths` and `GET` for `src/pages/covers/[slug].webp.ts`. */
25
+ export function coverEndpoint<T>(options: CoverEndpointOptions<T>): {
26
+ getStaticPaths: () => Promise<{ params: Record<string, string>; props: { entry: T } }[]>;
27
+ GET: (context: CoverRouteContext<T>) => Promise<Response>;
28
+ };
29
+
30
+ /** `'/covers/<slug>.webp'`. */
31
+ export function coverUrl(slug: string, options?: { base?: string; format?: keyof typeof MIME }): string;
package/src/astro.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Astro helpers: a static endpoint that renders one cover per entry.
3
+ *
4
+ * ```ts
5
+ * // src/pages/covers/[slug].webp.ts
6
+ * import { getCollection } from 'astro:content';
7
+ * import { coverEndpoint } from '@arraypress/covers/astro';
8
+ *
9
+ * export const { getStaticPaths, GET } = coverEndpoint({
10
+ * getEntries: () => getCollection('posts'),
11
+ * cover: (post) => ({ recipe: 'gridTitle', seed: post.id, title: post.data.title }),
12
+ * });
13
+ * ```
14
+ *
15
+ * `astro build` writes `dist/covers/<slug>.webp` for every entry; `astro
16
+ * dev` renders on request. Renders are cached by content hash in
17
+ * `node_modules/.cache/covers`, so an unchanged cover is read back instead
18
+ * of drawn again, across builds.
19
+ *
20
+ * No Astro import: the endpoint is plain `getStaticPaths` / `GET`
21
+ * functions, so this module works with any Astro version (and is easy to
22
+ * test).
23
+ *
24
+ * @module @arraypress/covers/astro
25
+ */
26
+
27
+ import { renderImage, MIME } from './index.js';
28
+
29
+ /**
30
+ * Build `getStaticPaths` and `GET` for a cover route.
31
+ *
32
+ * @template T
33
+ * @param {import('./astro.js').CoverEndpointOptions<T>} options
34
+ * @returns {{ getStaticPaths: () => Promise<{ params: Record<string, string>, props: { entry: T } }[]>, GET: (context: { params: Record<string, string | undefined>, props?: { entry?: T } }) => Promise<Response> }}
35
+ *
36
+ * @example
37
+ * export const { getStaticPaths, GET } = coverEndpoint({
38
+ * getEntries: () => getCollection('posts'),
39
+ * cover: (post) => ({ recipe: 'aurora', seed: post.id, title: post.data.title, palette: 'dusk' }),
40
+ * });
41
+ */
42
+ export function coverEndpoint(options) {
43
+ const { getEntries, cover } = options;
44
+ if (typeof getEntries !== 'function' || typeof cover !== 'function') {
45
+ throw new TypeError('coverEndpoint() needs getEntries and cover functions');
46
+ }
47
+ const param = options.param ?? 'slug';
48
+ const slugOf = options.slug ?? ((/** @type {any} */ e) => String(e.id ?? e.slug));
49
+ const format = options.format ?? 'webp';
50
+ const cache = options.cache ?? true;
51
+
52
+ const getStaticPaths = async () => {
53
+ const entries = await getEntries();
54
+ return entries.map((entry) => ({ params: { [param]: slugOf(entry) }, props: { entry } }));
55
+ };
56
+
57
+ /** @param {{ params: Record<string, string | undefined>, props?: { entry?: any } }} context */
58
+ const GET = async ({ params, props }) => {
59
+ let entry = props?.entry;
60
+ // On-demand routes get no props: look the entry up by its slug.
61
+ if (entry === undefined) entry = (await getEntries()).find((e) => slugOf(e) === params[param]);
62
+ if (entry === undefined) return new Response('Not found', { status: 404 });
63
+ const body = await renderImage(await cover(entry), { format, quality: options.quality, cache });
64
+ return new Response(new Uint8Array(body), {
65
+ headers: { 'Content-Type': MIME[format], 'Content-Length': String(body.length) },
66
+ });
67
+ };
68
+
69
+ return { getStaticPaths, GET };
70
+ }
71
+
72
+ /**
73
+ * The URL of an entry's cover, for `<img src>` and `og:image`.
74
+ *
75
+ * @param {string} slug - The entry's slug (the route param).
76
+ * @param {{ base?: string, format?: keyof typeof MIME }} [options] - Route base (default `'/covers'`) and extension (default `'webp'`).
77
+ * @returns {string} e.g. `'/covers/my-post.webp'`.
78
+ *
79
+ * @example
80
+ * coverUrl(post.id); // '/covers/hello-world.webp'
81
+ */
82
+ export function coverUrl(slug, options = {}) {
83
+ const base = (options.base ?? '/covers').replace(/\/+$/, '');
84
+ const path = String(slug).split('/').map(encodeURIComponent).join('/');
85
+ return `${base}/${path}.${options.format ?? 'webp'}`;
86
+ }
package/src/color.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Colour parsing and the few colour-space moves palettes need.
3
+ *
4
+ * Everything inside the library is a lowercase `#rrggbb` string. Input can
5
+ * be hex (3, 4, 6 or 8 digits), `rgb()`/`rgba()` or `hsl()`/`hsla()`; alpha
6
+ * is dropped, since a cover's layers carry their own `opacity`.
7
+ *
8
+ * Mixing and contrast come from `@arraypress/color-utils`; this module adds
9
+ * OKLCH, so derived palettes can rotate hue and set lightness perceptually
10
+ * instead of in sRGB (where "the same lightness" means very different
11
+ * things for yellow and blue).
12
+ *
13
+ * @module color
14
+ */
15
+
16
+ import { hexToRgb, rgbToHex, contrastRatio } from '@arraypress/color-utils';
17
+
18
+ /**
19
+ * Parse a CSS colour to `#rrggbb`.
20
+ *
21
+ * @param {string} input - `#abc`, `#aabbcc`, `#aabbccdd`, `rgb(…)`, `rgba(…)`, `hsl(…)` or `hsla(…)`.
22
+ * @returns {string} Lowercase `#rrggbb`.
23
+ * @throws {TypeError} When the string isn't a colour this function understands.
24
+ *
25
+ * @example
26
+ * toHex('rgb(37 99 235)'); // '#2563eb'
27
+ * toHex('#FA0'); // '#ffaa00'
28
+ * toHex('hsl(220 80% 50%)'); // '#1a5ee6'
29
+ */
30
+ export function toHex(input) {
31
+ if (typeof input !== 'string') throw new TypeError(`Expected a colour string, got ${typeof input}`);
32
+ const s = input.trim().toLowerCase();
33
+
34
+ const hex = s.match(/^#?([0-9a-f]{3,8})$/);
35
+ if (hex && [3, 4, 6, 8].includes(hex[1].length)) {
36
+ let h = hex[1];
37
+ if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
38
+ return '#' + h.slice(0, 6);
39
+ }
40
+
41
+ const fn = s.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
42
+ if (fn) {
43
+ const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
44
+ if (parts.length >= 3) {
45
+ if (fn[1].startsWith('rgb')) {
46
+ const [r, g, b] = parts.slice(0, 3).map((p) => (p.endsWith('%') ? parseFloat(p) * 2.55 : parseFloat(p)));
47
+ if ([r, g, b].every(Number.isFinite)) return rgbToHex(r, g, b);
48
+ } else {
49
+ const h = parseFloat(parts[0]);
50
+ const sat = parseFloat(parts[1]) / 100;
51
+ const l = parseFloat(parts[2]) / 100;
52
+ if ([h, sat, l].every(Number.isFinite)) return hslToHex(h, sat, l);
53
+ }
54
+ }
55
+ }
56
+ throw new TypeError(`Unrecognised colour: "${input}"`);
57
+ }
58
+
59
+ /**
60
+ * HSL to `#rrggbb`.
61
+ *
62
+ * @param {number} h - Hue in degrees.
63
+ * @param {number} s - Saturation, 0–1.
64
+ * @param {number} l - Lightness, 0–1.
65
+ * @returns {string} `#rrggbb`.
66
+ */
67
+ function hslToHex(h, s, l) {
68
+ const k = (n) => (n + h / 30) % 12;
69
+ const a = s * Math.min(l, 1 - l);
70
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
71
+ return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255);
72
+ }
73
+
74
+ /** @param {number} c - sRGB channel 0–1. @returns {number} Linear channel. */
75
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
76
+ /** @param {number} c - Linear channel. @returns {number} sRGB channel 0–1. */
77
+ const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
78
+
79
+ /**
80
+ * `#rrggbb` to OKLCH.
81
+ *
82
+ * @param {string} color - Any colour {@link toHex} accepts.
83
+ * @returns {{ l: number, c: number, h: number }} Lightness 0–1, chroma (~0–0.37), hue in degrees.
84
+ *
85
+ * @example
86
+ * toOklch('#2563eb'); // { l: 0.546, c: 0.215, h: 262.9 }
87
+ */
88
+ export function toOklch(color) {
89
+ const { r, g, b } = hexToRgb(toHex(color));
90
+ const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
91
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
92
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
93
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
94
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
95
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
96
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
97
+ const h = (Math.atan2(B, A) * 180) / Math.PI;
98
+ return { l: L, c: Math.hypot(A, B), h: h < 0 ? h + 360 : h };
99
+ }
100
+
101
+ /**
102
+ * OKLCH to `#rrggbb`. Out-of-gamut colours are brought in by reducing
103
+ * chroma (keeping lightness and hue), not by clipping channels, so a
104
+ * derived colour keeps the hue it was asked for.
105
+ *
106
+ * @param {{ l: number, c: number, h: number }} lch - Lightness 0–1, chroma, hue in degrees.
107
+ * @returns {string} `#rrggbb`.
108
+ *
109
+ * @example
110
+ * fromOklch({ l: 0.7, c: 0.12, h: 30 }); // a soft coral
111
+ */
112
+ export function fromOklch({ l, c, h }) {
113
+ const L = Math.max(0, Math.min(1, l));
114
+ let chroma = Math.max(0, c);
115
+ for (let i = 0; i < 24; i++) {
116
+ const rgb = oklabToRgb(L, chroma * Math.cos((h * Math.PI) / 180), chroma * Math.sin((h * Math.PI) / 180));
117
+ if (rgb.every((v) => v >= -0.0005 && v <= 1.0005) || chroma < 0.001) {
118
+ return toRgbHex(rgb);
119
+ }
120
+ chroma *= 0.9;
121
+ }
122
+ return toRgbHex(oklabToRgb(L, 0, 0));
123
+ }
124
+
125
+ /**
126
+ * Clamp 0–1 sRGB channels and write them as `#rrggbb`.
127
+ *
128
+ * @param {[number, number, number]} rgb
129
+ * @returns {string}
130
+ */
131
+ function toRgbHex([r, g, b]) {
132
+ const c = (/** @type {number} */ v) => Math.max(0, Math.min(1, v)) * 255;
133
+ return rgbToHex(c(r), c(g), c(b));
134
+ }
135
+
136
+ /**
137
+ * OKLab to gamma-encoded sRGB channels (0–1, possibly out of range).
138
+ *
139
+ * @param {number} L @param {number} A @param {number} B
140
+ * @returns {[number, number, number]} sRGB channels.
141
+ */
142
+ function oklabToRgb(L, A, B) {
143
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
144
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
145
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
146
+ return [
147
+ toGamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
148
+ toGamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
149
+ toGamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
150
+ ];
151
+ }
152
+
153
+ /**
154
+ * Mix two colours in OKLab (perceptually even, no muddy midpoints).
155
+ *
156
+ * @param {string} a - First colour.
157
+ * @param {string} b - Second colour.
158
+ * @param {number} t - 0 gives `a`, 1 gives `b`.
159
+ * @returns {string} `#rrggbb`.
160
+ *
161
+ * @example
162
+ * mixOklab('#ffffff', '#2563eb', 0.1); // a pale blue tint
163
+ */
164
+ export function mixOklab(a, b, t) {
165
+ const p = toLab(a);
166
+ const q = toLab(b);
167
+ const rgb = oklabToRgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
168
+ return toRgbHex(rgb);
169
+ }
170
+
171
+ /** @param {string} color @returns {[number, number, number]} OKLab. */
172
+ function toLab(color) {
173
+ const { l, c, h } = toOklch(color);
174
+ return [l, c * Math.cos((h * Math.PI) / 180), c * Math.sin((h * Math.PI) / 180)];
175
+ }
176
+
177
+ /**
178
+ * Mix two colours in OKLCH: lightness and chroma linearly, hue the short
179
+ * way round. Unlike OKLab, a mix of two far-apart hues stays vivid instead
180
+ * of passing through grey (orange → blue goes by way of pink, not mud).
181
+ *
182
+ * @param {string} a - First colour.
183
+ * @param {string} b - Second colour.
184
+ * @param {number} t - 0 gives `a`, 1 gives `b`.
185
+ * @returns {string} `#rrggbb`.
186
+ *
187
+ * @example
188
+ * mixOklch('#c9502a', '#a9bdf2', 0.5); // a clear rose, not a muddy mauve
189
+ */
190
+ export function mixOklch(a, b, t) {
191
+ const p = toOklch(a);
192
+ const q = toOklch(b);
193
+ // A near-grey has no meaningful hue: take the other colour's.
194
+ const ph = p.c < 0.02 ? q.h : p.h;
195
+ const qh = q.c < 0.02 ? p.h : q.h;
196
+ const dh = ((qh - ph + 540) % 360) - 180;
197
+ return fromOklch({ l: p.l + (q.l - p.l) * t, c: p.c + (q.c - p.c) * t, h: (ph + dh * t + 360) % 360 });
198
+ }
199
+
200
+ /** Near-black used wherever white doesn't read. */
201
+ export const NEAR_BLACK = '#111111';
202
+
203
+ /**
204
+ * A colour for a mark on one or more backgrounds: `prefer` when it clears
205
+ * `ratio` against every one of them, else whichever of white and
206
+ * near-black has the better worst case.
207
+ *
208
+ * For icons and other non-text marks WCAG 1.4.11 asks 3:1 (text needs
209
+ * 4.5:1), so `onBackground(bg, 3, 'white')` keeps white on mid-tone fills
210
+ * where a text rule would flip to black.
211
+ *
212
+ * @param {string | string[]} backgrounds - The colour(s) under the mark.
213
+ * @param {number} [ratio=3] - Contrast to clear.
214
+ * @param {'white' | 'black' | string} [prefer='white'] - The colour to use when it clears `ratio`.
215
+ * @returns {string} `#rrggbb`.
216
+ *
217
+ * @example
218
+ * onBackground('#e0533d', 3, 'white'); // '#ffffff' (3.6:1)
219
+ * onBackground('#ffe14d', 3, 'white'); // '#111111'
220
+ */
221
+ export function onBackground(backgrounds, ratio = 3, prefer = 'white') {
222
+ const bgs = [].concat(/** @type {any} */ (backgrounds)).map(toHex);
223
+ const want = prefer === 'white' ? '#ffffff' : prefer === 'black' ? NEAR_BLACK : toHex(prefer);
224
+ const worst = (/** @type {string} */ c) => Math.min(...bgs.map((b) => contrastRatio(c, b)));
225
+ if (worst(want) >= ratio) return want;
226
+ return worst('#ffffff') >= worst(NEAR_BLACK) ? '#ffffff' : NEAR_BLACK;
227
+ }
228
+
229
+ /**
230
+ * The colour of an eased multi-stop gradient at `t` (0–1), the way the
231
+ * `linear` and `radial` layers draw it (smoothstep between stops, in OKLab).
232
+ *
233
+ * @param {string[]} colors - Two or more stops, evenly spaced.
234
+ * @param {number} t - Position along the gradient.
235
+ * @param {boolean} [ease=true] - Ease between stops.
236
+ * @returns {string} `#rrggbb`.
237
+ */
238
+ export function gradientAt(colors, t, ease = true) {
239
+ const list = colors.map(toHex);
240
+ if (list.length === 1) return list[0];
241
+ const n = list.length - 1;
242
+ const x = Math.max(0, Math.min(1, t)) * n;
243
+ const i = Math.min(n - 1, Math.floor(x));
244
+ const f = x - i;
245
+ return mixOklab(list[i], list[i + 1], ease ? f * f * (3 - 2 * f) : f);
246
+ }
package/src/diagram.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The `diagram` layer: white line-art over a field (the engineering-blog
3
+ * look). One icon, or a few nodes (icons in outlined square or circle
4
+ * tiles) joined by arrows, with an optional dotted grid that breaks around
5
+ * the drawing instead of running through it.
6
+ *
7
+ * Layouts (node centres, relative to `position`):
8
+ *
9
+ * - `single` one node
10
+ * - `row` 2–4 nodes side by side; `arrows: 'loop'` adds an elbow back from the last to the first
11
+ * - `triangle` one on top, two below; arrows from the top to each (`'both'`: there and back)
12
+ * - `square` four corners, arrows round the cycle; a fifth node sits in the middle with dotted spokes and corner brackets
13
+ * - `stack` two on top merging into one below
14
+ * - `hub` a large first node with the rest around its edge, knocked out of it (a globe with databases)
15
+ *
16
+ * Everything is stroked in one colour at one width; stroke icons (Lucide)
17
+ * are rescaled so their lines match the tiles' lines.
18
+ *
19
+ * @module diagram
20
+ */
21
+
22
+ import { attrs, num, layerAttrs } from './svg.js';
23
+ import { len } from './geometry.js';
24
+ import { toHex } from './color.js';
25
+ import { resolveIcon, parseSvgIcon } from './icons.js';
26
+ import { gridPath } from './grid.js';
27
+
28
+ /** A four-point spark: the glyph for a node with no icon. */
29
+ const SPARK = 'M12 2c.4 4.9 2.1 7.6 7 8.6l3 .4-3 .4c-4.9 1-6.6 3.7-7 8.6-.4-4.9-2.1-7.6-7-8.6L2 11l3-.4c4.9-1 6.6-3.7 7-8.6Z';
30
+
31
+ /**
32
+ * Resolve a node's icon to markup + viewBox.
33
+ *
34
+ * @param {string | undefined} icon - Iconify name, raw SVG, or nothing (the spark).
35
+ * @returns {{ body: string, viewBox: number[] }}
36
+ */
37
+ function iconData(icon) {
38
+ if (typeof icon === 'string' && icon.trim().startsWith('<')) return parseSvgIcon(icon);
39
+ if (typeof icon === 'string' && icon) return resolveIcon(icon);
40
+ return { body: `<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" d="${SPARK}"/>`, viewBox: [0, 0, 24, 24] };
41
+ }
42
+
43
+ /**
44
+ * An open arrowhead (two strokes) at `to`, pointing away from `from`.
45
+ *
46
+ * @param {[number, number]} from @param {[number, number]} to
47
+ * @param {number} size - Arm length.
48
+ * @returns {string} Path data.
49
+ */
50
+ function head(from, to, size) {
51
+ const a = Math.atan2(to[1] - from[1], to[0] - from[0]);
52
+ const arm = (t) => [to[0] - size * Math.cos(a + t), to[1] - size * Math.sin(a + t)];
53
+ const [l, r] = [arm(0.55), arm(-0.55)];
54
+ return `M${num(l[0])} ${num(l[1])}L${num(to[0])} ${num(to[1])}L${num(r[0])} ${num(r[1])}`;
55
+ }
56
+
57
+ /**
58
+ * The `diagram` layer.
59
+ *
60
+ * @param {import('./index.js').DiagramLayer} layer
61
+ * @param {import('./layers.js').RenderContext} ctx
62
+ * @returns {import('./layers.js').Markup}
63
+ */
64
+ export function diagram(layer, ctx) {
65
+ const { width: w, height: h } = ctx;
66
+ const nodesIn = layer.nodes?.length ? layer.nodes : [{ icon: layer.icon, shape: layer.shape ?? (layer.icon ? 'none' : 'square') }];
67
+ const n = nodesIn.length;
68
+ const layout = layer.layout ?? (n === 1 ? 'single' : n === 3 && layer.arrows === 'merge' ? 'stack' : n === 4 && layer.arrows === 'cycle' ? 'square' : 'row');
69
+ const vmin = Math.min(w, h) / 100;
70
+ const defaultSize = { single: nodesIn[0].shape === 'none' ? 40 : 22, row: n >= 4 ? 15 : 19, triangle: 19, square: 12, stack: 18, hub: 13 }[layout] ?? 18;
71
+ const s = len(layer.size, ctx.canvas, defaultSize * vmin);
72
+ const gap = len(layer.gap, ctx.canvas, s * 0.45);
73
+ const stroke = len(layer.stroke, ctx.canvas, '0.3vmin');
74
+ const col = layer.color ? toHex(layer.color) : '#ffffff';
75
+ const cx = (layer.position?.x ?? 0.5) * w;
76
+ const cy = (layer.position?.y ?? 0.5) * h;
77
+ const radius = len(layer.radius, ctx.canvas, s * 0.14);
78
+ const ah = Math.max(stroke * 3.5, s * 0.09);
79
+ const pad = s * 0.1;
80
+
81
+ // Node centres and sizes.
82
+ const d = s + gap;
83
+ /** @type {{ x: number, y: number, size: number, shape: string, icon?: string, brackets?: boolean, knock?: boolean }[]} */
84
+ const nodes = nodesIn.map((nd, i) => {
85
+ let x = 0;
86
+ let y = 0;
87
+ let size = nd.size != null ? len(nd.size, ctx.canvas) : s;
88
+ if (layout === 'row') x = (i - (n - 1) / 2) * d;
89
+ else if (layout === 'triangle') [x, y] = [[0, -d * 0.52], [-d * 0.6, d * 0.5], [d * 0.6, d * 0.5]][i] ?? [0, 0];
90
+ else if (layout === 'square') {
91
+ const half = (s + gap * 2.6) / 2 + s * 0.6;
92
+ [x, y] = [[-half, -half], [half, -half], [half, half], [-half, half], [0, 0]][i] ?? [0, 0];
93
+ } else if (layout === 'stack') [x, y] = [[-d * 0.58, -d * 0.5], [d * 0.58, -d * 0.5], [0, d * 0.55]][i] ?? [0, 0];
94
+ else if (layout === 'hub') {
95
+ if (i === 0) size = nd.size != null ? len(nd.size, ctx.canvas) : s * 3;
96
+ else {
97
+ const big = nodesIn[0].size != null ? len(nodesIn[0].size, ctx.canvas) : s * 3;
98
+ const spots = [[-1, -0.15], [1, -0.15], [0, 0.95], [0, -1]];
99
+ const [ux, uy] = spots[(i - 1) % spots.length];
100
+ [x, y] = [(ux * big) / 2, (uy * big) / 2];
101
+ }
102
+ }
103
+ return {
104
+ x: cx + x,
105
+ y: cy + y,
106
+ size,
107
+ shape: nd.shape ?? (layout === 'hub' ? 'none' : layout === 'square' ? 'circle' : 'square'),
108
+ icon: nd.icon,
109
+ brackets: nd.brackets ?? (layout === 'square' && i === 4),
110
+ knock: layout === 'hub' && i > 0,
111
+ };
112
+ });
113
+
114
+ /** The point on a node's outline toward another point, padded. */
115
+ const edge = (/** @type {typeof nodes[number]} */ nd, /** @type {[number, number]} */ toward) => {
116
+ const dx = toward[0] - nd.x;
117
+ const dy = toward[1] - nd.y;
118
+ const r = nd.size / 2 + pad;
119
+ if (nd.shape === 'circle' || nd.shape === 'none') {
120
+ const l = Math.hypot(dx, dy) || 1;
121
+ return /** @type {[number, number]} */ ([nd.x + (dx / l) * r, nd.y + (dy / l) * r]);
122
+ }
123
+ const t = r / Math.max(Math.abs(dx), Math.abs(dy), 1e-6);
124
+ return /** @type {[number, number]} */ ([nd.x + dx * t, nd.y + dy * t]);
125
+ };
126
+
127
+ const lines = [];
128
+ const dotted = [];
129
+ /** @param {number} a @param {number} b @param {number} [offset] - Perpendicular shift (for there-and-back pairs). */
130
+ const arrow = (a, b, offset = 0) => {
131
+ const A = nodes[a];
132
+ const B = nodes[b];
133
+ let p = edge(A, [B.x, B.y]);
134
+ let q = edge(B, [A.x, A.y]);
135
+ if (offset) {
136
+ const l = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1;
137
+ const [ox, oy] = [(-(q[1] - p[1]) / l) * offset, ((q[0] - p[0]) / l) * offset];
138
+ p = [p[0] + ox, p[1] + oy];
139
+ q = [q[0] + ox, q[1] + oy];
140
+ }
141
+ lines.push(`M${num(p[0])} ${num(p[1])}L${num(q[0])} ${num(q[1])}${head(p, q, ah)}`);
142
+ };
143
+
144
+ const mode = layer.arrows ?? (layout === 'single' || layout === 'hub' ? false : layout === 'square' ? 'cycle' : true);
145
+ if (mode) {
146
+ if (layout === 'row') {
147
+ for (let i = 0; i < n - 1; i++) arrow(i, i + 1);
148
+ if (mode === 'loop' && n > 1) {
149
+ // An elbow under the row, from the last node back to the first.
150
+ const A = nodes[n - 1];
151
+ const B = nodes[0];
152
+ const top = A.y + A.size / 2 + pad;
153
+ const low = top + gap * 1.1;
154
+ const r = Math.min(gap * 0.35, s * 0.15);
155
+ lines.push(`M${num(A.x)} ${num(top)}V${num(low - r)}Q${num(A.x)} ${num(low)} ${num(A.x - r)} ${num(low)}H${num(B.x + r)}Q${num(B.x)} ${num(low)} ${num(B.x)} ${num(low - r)}V${num(top)}${head([B.x, top + 1], [B.x, top], ah)}`);
156
+ }
157
+ } else if (layout === 'triangle') {
158
+ const off = mode === 'both' ? s * 0.12 : 0;
159
+ for (const b of [1, 2]) {
160
+ if (nodes[b]) {
161
+ arrow(0, b, off);
162
+ if (mode === 'both') arrow(b, 0, off);
163
+ }
164
+ }
165
+ } else if (layout === 'square') {
166
+ for (let i = 0; i < Math.min(4, n); i++) arrow(i, (i + 1) % Math.min(4, n));
167
+ if (n > 4) {
168
+ const C = nodes[4];
169
+ for (let i = 0; i < 4; i++) {
170
+ const p = edge(C, [nodes[i].x, nodes[i].y]);
171
+ const q = edge(nodes[i], [C.x, C.y]);
172
+ dotted.push(`M${num(p[0])} ${num(p[1])}L${num(q[0])} ${num(q[1])}`);
173
+ }
174
+ }
175
+ } else if (layout === 'stack' && n >= 3) {
176
+ const [L, R, B] = nodes;
177
+ const top = L.y + L.size / 2 + pad;
178
+ const mid = (top + (B.y - B.size / 2 - pad)) / 2;
179
+ const r = Math.min(gap * 0.3, s * 0.12);
180
+ lines.push(`M${num(L.x)} ${num(top)}V${num(mid - r)}Q${num(L.x)} ${num(mid)} ${num(L.x + r)} ${num(mid)}H${num(R.x - r)}Q${num(R.x)} ${num(mid)} ${num(R.x)} ${num(mid - r)}V${num(top)}`);
181
+ const end = B.y - B.size / 2 - pad;
182
+ lines.push(`M${num(B.x)} ${num(mid)}V${num(end)}${head([B.x, end - 1], [B.x, end], ah)}`);
183
+ }
184
+ }
185
+
186
+ // Tiles, icons, brackets.
187
+ let tiles = '';
188
+ /** @type {string[]} */
189
+ const icons = [];
190
+ let knockouts = '';
191
+ for (const nd of nodes) {
192
+ const half = nd.size / 2;
193
+ if (nd.shape === 'square') tiles += `<rect${attrs({ x: nd.x - half, y: nd.y - half, width: nd.size, height: nd.size, rx: Math.min(radius, half) })}/>`;
194
+ else if (nd.shape === 'circle') tiles += `<circle${attrs({ cx: nd.x, cy: nd.y, r: half })}/>`;
195
+ if (nd.brackets) {
196
+ const o = half + pad * 1.6;
197
+ const arm = nd.size * 0.14;
198
+ for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
199
+ const [bx, by] = [nd.x + sx * o, nd.y + sy * o];
200
+ lines.push(`M${num(bx - sx * arm)} ${num(by)}H${num(bx)}V${num(by - sy * arm)}`);
201
+ }
202
+ }
203
+ if (nd.knock) knockouts += `<circle${attrs({ cx: nd.x, cy: nd.y, r: half * 1.08, fill: '#000' })}/>`;
204
+ const data = iconData(nd.icon);
205
+ const [vx, vy, vw, vh] = data.viewBox;
206
+ const box = nd.shape === 'none' ? nd.size : nd.size * (layer.iconScale ?? 0.44);
207
+ const k = Math.min(box / vw, box / vh);
208
+ // Match the icon's line weight to the tiles' (in icon units).
209
+ const body = data.body.replace(/stroke-width="[^"]*"/g, `stroke-width="${num(stroke / k, 3)}"`);
210
+ icons.push(`<svg${attrs({ x: nd.x - (vw * k) / 2, y: nd.y - (vh * k) / 2, width: vw * k, height: vh * k, viewBox: `${num(vx)} ${num(vy)} ${num(vw)} ${num(vh)}`, overflow: 'visible', color: col })}>${body}</svg>`);
211
+ }
212
+
213
+ let defs = '';
214
+ const paint = { fill: 'none', stroke: col, 'stroke-width': stroke, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' };
215
+ let out = '';
216
+
217
+ // The grid behind, broken around every node (and the arrows' span).
218
+ if (layer.grid) {
219
+ const g = layer.grid === true ? {} : layer.grid;
220
+ const div = g.divisions ?? 4;
221
+ const gpad = len(g.pad, ctx.canvas, s * 0.35);
222
+ const gaps = nodes.map((nd) => ({ x: nd.x - nd.size / 2 - gpad, y: nd.y - nd.size / 2 - gpad, width: nd.size + gpad * 2, height: nd.size + gpad * 2 }));
223
+ const xs0 = Math.min(...gaps.map((r) => r.x));
224
+ const ys0 = Math.min(...gaps.map((r) => r.y));
225
+ const xs1 = Math.max(...gaps.map((r) => r.x + r.width));
226
+ const ys1 = Math.max(...gaps.map((r) => r.y + r.height));
227
+ // Arrows run between nodes: keep the whole drawing's span clear too.
228
+ if (lines.length) gaps.push({ x: xs0, y: ys0, width: xs1 - xs0, height: ys1 - ys0 + (layout === 'row' && mode === 'loop' ? gap * 1.3 : 0) });
229
+ const xs = Array.from({ length: div - 1 }, (_, i) => ((i + 1) * w) / div);
230
+ const ys = Array.from({ length: div - 1 }, (_, i) => ((i + 1) * h) / div);
231
+ const gs = len(g.stroke, ctx.canvas, '0.3vmin');
232
+ out += `<path${attrs({ d: gridPath(xs, ys, w, h, gaps), fill: 'none', stroke: g.color ? toHex(g.color) : col, 'stroke-width': gs, 'stroke-linecap': 'round', 'stroke-dasharray': `0 ${num(gs * 3.2)}`, opacity: g.opacity ?? 0.6 })}/>`;
233
+ }
234
+
235
+ if (dotted.length) out += `<path${attrs({ d: dotted.join(''), ...paint, 'stroke-dasharray': `0 ${num(stroke * 3)}` })}/>`;
236
+ if (lines.length) out += `<path${attrs({ d: lines.join(''), ...paint })}/>`;
237
+ if (tiles) out += `<g${attrs(paint)}>${tiles}</g>`;
238
+ if (knockouts) {
239
+ // The hub is drawn with holes where its satellites sit.
240
+ const mid = `${ctx.id}k`;
241
+ defs += `<mask${attrs({ id: mid, maskUnits: 'userSpaceOnUse', x: 0, y: 0, width: w, height: h })}><rect${attrs({ width: w, height: h, fill: '#fff' })}/>${knockouts}</mask>`;
242
+ icons[0] = `<g mask="url(#${mid})">${icons[0]}</g>`;
243
+ }
244
+ out += icons.join('');
245
+ const a = attrs(layerAttrs(layer));
246
+ return { defs, body: a ? `<g${a}>${out}</g>` : out };
247
+ }
248
+