@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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +29 -0
- package/README.md +561 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +96 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +2292 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/extract.js +90 -0
- package/src/flatten.js +234 -0
- package/src/fonts.js +200 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +157 -0
- package/src/index.d.ts +897 -0
- package/src/index.js +19 -0
- package/src/json-recipe.js +458 -0
- package/src/layer-options.js +37 -0
- package/src/layers.js +972 -0
- package/src/palette.js +185 -0
- package/src/png.js +77 -0
- package/src/print.js +658 -0
- package/src/recipes.js +78 -0
- package/src/render.js +462 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.js +230 -0
package/src/print.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print-shop layers: `ink` (risograph), `dither`, `contour`, `waveform`,
|
|
3
|
+
* `tiles` (a Bauhaus grid) and `rule` (a hairline).
|
|
4
|
+
*
|
|
5
|
+
* @module print
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { attrs, num, layerAttrs, esc } from './svg.js';
|
|
9
|
+
import { len, regionPx } from './geometry.js';
|
|
10
|
+
import { toHex } from './color.js';
|
|
11
|
+
import { createRng } from './rng.js';
|
|
12
|
+
import { encodePng, pngUri } from './png.js';
|
|
13
|
+
import { text as textLayer } from './layers.js';
|
|
14
|
+
|
|
15
|
+
/** Classic risograph inks, by name (`'riso:pink'`). Approximations, not trademarks. */
|
|
16
|
+
export const RISO_INKS = Object.freeze({
|
|
17
|
+
pink: '#ff48b0',
|
|
18
|
+
blue: '#0078bf',
|
|
19
|
+
yellow: '#ffe800',
|
|
20
|
+
teal: '#00838a',
|
|
21
|
+
red: '#ff665e',
|
|
22
|
+
green: '#00a95c',
|
|
23
|
+
orange: '#ff6c2f',
|
|
24
|
+
purple: '#765ba7',
|
|
25
|
+
navy: '#3d5588',
|
|
26
|
+
black: '#1a1a1a',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A colour, or a riso ink by name (`'riso:pink'`).
|
|
31
|
+
*
|
|
32
|
+
* @param {string} c
|
|
33
|
+
* @returns {string} `#rrggbb`.
|
|
34
|
+
*/
|
|
35
|
+
export function inkColor(c) {
|
|
36
|
+
const m = /^riso:(\w+)$/.exec(String(c));
|
|
37
|
+
if (m) {
|
|
38
|
+
const hex = /** @type {Record<string, string>} */ (RISO_INKS)[m[1]];
|
|
39
|
+
if (!hex) throw new TypeError(`Unknown riso ink "${m[1]}" (${Object.keys(RISO_INKS).join(', ')})`);
|
|
40
|
+
return hex;
|
|
41
|
+
}
|
|
42
|
+
return toHex(c);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {any} v @param {string} f @returns {string} */
|
|
46
|
+
const col = (v, f) => (v ? toHex(v) : f);
|
|
47
|
+
|
|
48
|
+
const wrap = (/** @type {any} */ layer, /** @type {string} */ body) => {
|
|
49
|
+
const a = attrs(layerAttrs(layer));
|
|
50
|
+
return a ? `<g${a}>${body}</g>` : body;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// ink (riso)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `ink` — one risograph ink: flat shapes (or text) printed with `multiply`
|
|
59
|
+
* (or `screen` on dark paper), slightly misregistered, with a stochastic
|
|
60
|
+
* or halftone texture and uneven density across the sheet.
|
|
61
|
+
*
|
|
62
|
+
* @param {import('./index.js').InkLayer} layer
|
|
63
|
+
* @param {import('./layers.js').RenderContext} ctx
|
|
64
|
+
* @returns {Promise<import('./layers.js').Markup>}
|
|
65
|
+
*/
|
|
66
|
+
export async function ink(layer, ctx) {
|
|
67
|
+
const { width: w, height: h, rng } = ctx;
|
|
68
|
+
const color = layer.color ? inkColor(layer.color) : ctx.palette.accent;
|
|
69
|
+
const min = Math.min(w, h);
|
|
70
|
+
const id = ctx.id;
|
|
71
|
+
|
|
72
|
+
// Shapes, in this ink.
|
|
73
|
+
let shapes = '';
|
|
74
|
+
for (const s of layer.shapes ?? [{ kind: 'circle' }]) {
|
|
75
|
+
const x = (s.x ?? 0.5) * w;
|
|
76
|
+
const y = (s.y ?? 0.5) * h;
|
|
77
|
+
const r = len(s.r ?? s.size, ctx.canvas, '30vmin');
|
|
78
|
+
const rot = s.rotate ?? 0;
|
|
79
|
+
const tr = rot ? ` rotate(${num(rot)} ${num(x)} ${num(y)})` : '';
|
|
80
|
+
if (s.kind === 'circle') shapes += `<circle${attrs({ cx: x, cy: y, r })}/>`;
|
|
81
|
+
else if (s.kind === 'ring') shapes += `<circle${attrs({ cx: x, cy: y, r, fill: 'none', stroke: color, 'stroke-width': len(s.thickness, ctx.canvas, r * 0.3) })}/>`;
|
|
82
|
+
else if (s.kind === 'half') shapes += `<path${attrs({ d: `M${num(x - r)} ${num(y)}A${num(r)} ${num(r)} 0 0 1 ${num(x + r)} ${num(y)}Z`, transform: tr.trim() || undefined })}/>`;
|
|
83
|
+
else if (s.kind === 'arch') {
|
|
84
|
+
const aw = len(s.width, ctx.canvas, r * 2);
|
|
85
|
+
const ah = len(s.height, ctx.canvas, aw * 1.3);
|
|
86
|
+
const ar = aw / 2;
|
|
87
|
+
shapes += `<path${attrs({ d: `M${num(x - ar)} ${num(y + ah / 2)}V${num(y - ah / 2 + ar)}A${num(ar)} ${num(ar)} 0 0 1 ${num(x + ar)} ${num(y - ah / 2 + ar)}V${num(y + ah / 2)}Z`, transform: tr.trim() || undefined })}/>`;
|
|
88
|
+
} else if (s.kind === 'band') {
|
|
89
|
+
const bh = len(s.height, ctx.canvas, '22vmin');
|
|
90
|
+
const reach = Math.hypot(w, h);
|
|
91
|
+
shapes += `<rect${attrs({ x: w / 2 - reach, y: y - bh / 2, width: reach * 2, height: bh, transform: `rotate(${num(rot)} ${num(w / 2)} ${num(y)})` })}/>`;
|
|
92
|
+
} else if (s.kind === 'rect') {
|
|
93
|
+
const rw = len(s.width, ctx.canvas, r * 2);
|
|
94
|
+
const rh = len(s.height, ctx.canvas, r * 2);
|
|
95
|
+
shapes += `<rect${attrs({ x: x - rw / 2, y: y - rh / 2, width: rw, height: rh, transform: tr.trim() || undefined })}/>`;
|
|
96
|
+
} else if (s.kind === 'text') {
|
|
97
|
+
const t = await textLayer(/** @type {any} */ ({ type: 'text', balance: false, maxLines: 2, weight: 700, letterSpacing: -0.04, lineHeight: 0.92, ...s, kind: undefined, color }), ctx);
|
|
98
|
+
shapes += t.body;
|
|
99
|
+
} else {
|
|
100
|
+
throw new TypeError(`Unknown ink shape "${s.kind}" (circle, ring, half, arch, band, rect, text)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Misregistration: the whole plate shifted a little.
|
|
105
|
+
const shift = layer.offset ?? (() => {
|
|
106
|
+
const a = rng.range(0, Math.PI * 2);
|
|
107
|
+
const d = rng.range(0.003, 0.01) * min;
|
|
108
|
+
return { x: Math.cos(a) * d, y: Math.sin(a) * d };
|
|
109
|
+
})();
|
|
110
|
+
const ox = len(shift.x, ctx.canvas, 0);
|
|
111
|
+
const oy = len(shift.y, ctx.canvas, 0);
|
|
112
|
+
|
|
113
|
+
// Texture: speckle (paper showing through) or a halftone screen, over the
|
|
114
|
+
// canvas plus the misregistration (masks cost by area: keep it tight).
|
|
115
|
+
const pad = Math.abs(ox) + Math.abs(oy) + 2;
|
|
116
|
+
const bleed = { x: -pad, y: -pad, width: w + pad * 2, height: h + pad * 2 };
|
|
117
|
+
let defs = '';
|
|
118
|
+
// The plate's shift moves the shapes, not the texture masks: a mask under
|
|
119
|
+
// a transform drops librsvg onto a slow path (~10× the cost).
|
|
120
|
+
let inner = `<g fill="${color}" transform="translate(${num(ox)} ${num(oy)})">${shapes}</g>`;
|
|
121
|
+
const texture = layer.texture ?? 'grain';
|
|
122
|
+
if (texture === 'grain') {
|
|
123
|
+
const size = 160;
|
|
124
|
+
const px = new Uint8Array(size * size);
|
|
125
|
+
const hole = layer.speckle ?? 0.09;
|
|
126
|
+
for (let i = 0; i < px.length; i++) px[i] = rng.next() < hole ? 0 : 255;
|
|
127
|
+
// Clump the holes a little (riso misses ink in specks, not single pixels).
|
|
128
|
+
for (let i = 0; i < px.length; i++) if (px[i] === 0 && rng.next() < 0.5) px[(i + 1) % px.length] = 0;
|
|
129
|
+
defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: size, height: size })}><image${attrs({ width: size, height: size, href: pngUri(encodePng(px, size, size, 1)) })}/></pattern>`;
|
|
130
|
+
defs += `<mask${attrs({ id: `${id}tm`, maskUnits: 'userSpaceOnUse', ...bleed })}><rect${attrs({ ...bleed, fill: `url(#${id}t)` })}/></mask>`;
|
|
131
|
+
inner = `<g mask="url(#${id}tm)">${inner}</g>`;
|
|
132
|
+
} else if (texture === 'halftone') {
|
|
133
|
+
const cell = len(layer.cell, ctx.canvas, '1.1vmin');
|
|
134
|
+
// Screens at 0° or 45°, drawn into a PNG tile of whole cells (a 45° screen
|
|
135
|
+
// is a square tile with dots at its centre and corners). An SVG pattern of
|
|
136
|
+
// one dot per cell costs librsvg ~1.5s a canvas; an image tile, ~15ms.
|
|
137
|
+
const angle = layer.angle ?? rng.pick([0, 45]);
|
|
138
|
+
const c = Math.max(3, Math.round(cell));
|
|
139
|
+
const r = c * (layer.dot ?? 0.46) * (angle === 45 ? 0.72 : 1);
|
|
140
|
+
const per = 12;
|
|
141
|
+
const T = c * per;
|
|
142
|
+
const px = new Uint8Array(T * T);
|
|
143
|
+
const centres = angle === 45 ? [[0.5, 0.5], [0, 0], [1, 0], [0, 1], [1, 1]] : [[0.5, 0.5]];
|
|
144
|
+
for (let y = 0; y < T; y++) {
|
|
145
|
+
for (let x = 0; x < T; x++) {
|
|
146
|
+
// 2×2 supersampling for a clean dot edge.
|
|
147
|
+
let hit = 0;
|
|
148
|
+
for (const [sx, sy] of [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]) {
|
|
149
|
+
const fx = ((x + sx) % c) / c;
|
|
150
|
+
const fy = ((y + sy) % c) / c;
|
|
151
|
+
if (centres.some(([cx, cy]) => Math.hypot(fx - cx, fy - cy) * c <= r)) hit++;
|
|
152
|
+
}
|
|
153
|
+
px[y * T + x] = Math.round((hit / 4) * 255);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: T, height: T })}><image${attrs({ width: T, height: T, href: pngUri(encodePng(px, T, T, 1)) })}/></pattern>`;
|
|
157
|
+
defs += `<mask${attrs({ id: `${id}tm`, maskUnits: 'userSpaceOnUse', ...bleed })}><rect${attrs({ ...bleed, fill: `url(#${id}t)` })}/></mask>`;
|
|
158
|
+
inner = `<g mask="url(#${id}tm)">${inner}</g>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Uneven density: the ink thins out across the sheet.
|
|
162
|
+
const [dmin, dmax] = layer.density ?? [0.78, 1];
|
|
163
|
+
if (dmin < 1) {
|
|
164
|
+
const a = rng.range(0, Math.PI * 2);
|
|
165
|
+
const gx = Math.cos(a) / 2;
|
|
166
|
+
const gy = Math.sin(a) / 2;
|
|
167
|
+
defs += `<linearGradient${attrs({ id: `${id}d`, x1: 0.5 - gx, y1: 0.5 - gy, x2: 0.5 + gx, y2: 0.5 + gy })}><stop offset="0" stop-color="#fff" stop-opacity="${num(dmax, 3)}"/><stop offset="1" stop-color="#fff" stop-opacity="${num(dmin, 3)}"/></linearGradient>`;
|
|
168
|
+
defs += `<mask${attrs({ id: `${id}dm`, maskUnits: 'userSpaceOnUse', x: 0, y: 0, width: w, height: h })}><rect${attrs({ width: w, height: h, fill: `url(#${id}d)` })}/></mask>`;
|
|
169
|
+
inner = `<g mask="url(#${id}dm)">${inner}</g>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const blend = layer.blend ?? 'multiply';
|
|
173
|
+
const body = `<g${attrs({ style: blend === 'normal' ? undefined : `mix-blend-mode:${esc(blend)}`, opacity: layer.opacity != null && layer.opacity < 1 ? layer.opacity : undefined })}>${inner}</g>`;
|
|
174
|
+
return { defs, body };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Scalar fields (dither, contour)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Seeded value noise in 2D, smooth, in [0, 1], with octaves.
|
|
183
|
+
*
|
|
184
|
+
* @param {import('./rng.js').Rng} rng
|
|
185
|
+
* @param {number} octaves
|
|
186
|
+
* @returns {(x: number, y: number) => number}
|
|
187
|
+
*/
|
|
188
|
+
export function valueNoise(rng, octaves = 3) {
|
|
189
|
+
const N = 64;
|
|
190
|
+
const grid = Float64Array.from({ length: N * N }, () => rng.next());
|
|
191
|
+
const ox = rng.range(0, N);
|
|
192
|
+
const oy = rng.range(0, N);
|
|
193
|
+
const at = (/** @type {number} */ i, /** @type {number} */ j) => grid[(((j % N) + N) % N) * N + (((i % N) + N) % N)];
|
|
194
|
+
const smooth = (/** @type {number} */ t) => t * t * t * (t * (t * 6 - 15) + 10);
|
|
195
|
+
const base = (/** @type {number} */ x, /** @type {number} */ y) => {
|
|
196
|
+
const i = Math.floor(x);
|
|
197
|
+
const j = Math.floor(y);
|
|
198
|
+
const fx = smooth(x - i);
|
|
199
|
+
const fy = smooth(y - j);
|
|
200
|
+
const a = at(i, j) + (at(i + 1, j) - at(i, j)) * fx;
|
|
201
|
+
const b = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * fx;
|
|
202
|
+
return a + (b - a) * fy;
|
|
203
|
+
};
|
|
204
|
+
return (x, y) => {
|
|
205
|
+
let v = 0;
|
|
206
|
+
let amp = 1;
|
|
207
|
+
let freq = 1;
|
|
208
|
+
let total = 0;
|
|
209
|
+
for (let o = 0; o < octaves; o++) {
|
|
210
|
+
v += base(x * freq + ox, y * freq + oy) * amp;
|
|
211
|
+
total += amp;
|
|
212
|
+
amp *= 0.5;
|
|
213
|
+
freq *= 2;
|
|
214
|
+
}
|
|
215
|
+
return v / total;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A 4×4 and an 8×8 Bayer matrix, normalised to (0, 1). */
|
|
220
|
+
const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);
|
|
221
|
+
const BAYER8 = (() => {
|
|
222
|
+
const m = [];
|
|
223
|
+
for (let y = 0; y < 8; y++) {
|
|
224
|
+
for (let x = 0; x < 8; x++) {
|
|
225
|
+
const b4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5][(y % 4) * 4 + (x % 4)];
|
|
226
|
+
const q = [0, 2, 3, 1][(y >> 2) * 2 + (x >> 2)];
|
|
227
|
+
m.push((b4 * 4 + q + 0.5) / 64);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return m;
|
|
231
|
+
})();
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// dither
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `dither` — a soft field (gradient, noise, or a lit sphere / arch / circle)
|
|
239
|
+
* rendered 1-bit at a visible cell size, ordered (Bayer 4×4 / 8×8) or by
|
|
240
|
+
* blue-ish noise, in two colours (or three: two dithered bands). Embedded
|
|
241
|
+
* as a tiny PNG drawn with nearest-neighbour scaling, so cells stay crisp.
|
|
242
|
+
*
|
|
243
|
+
* @param {import('./index.js').DitherLayer} layer
|
|
244
|
+
* @param {import('./layers.js').RenderContext} ctx
|
|
245
|
+
* @returns {import('./layers.js').Markup}
|
|
246
|
+
*/
|
|
247
|
+
export function dither(layer, ctx) {
|
|
248
|
+
const { width: w, height: h, rng, palette: p } = ctx;
|
|
249
|
+
const cell = Math.max(1, Math.round(len(layer.cell, ctx.canvas, '0.9vmin')));
|
|
250
|
+
const cw = Math.ceil(w / cell);
|
|
251
|
+
const chh = Math.ceil(h / cell);
|
|
252
|
+
const colors = (layer.colors?.length ? layer.colors : [p.bg, p.accent]).map((c) => toHex(c));
|
|
253
|
+
const rgb = colors.map((c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)));
|
|
254
|
+
const matrix = layer.matrix ?? 'bayer4';
|
|
255
|
+
const source = layer.source ?? 'field';
|
|
256
|
+
const angle = ((layer.angle ?? rng.range(0, 360)) * Math.PI) / 180;
|
|
257
|
+
const noise = valueNoise(rng, 3);
|
|
258
|
+
const scale = layer.scale ?? 2.2;
|
|
259
|
+
const shape = layer.shape ?? 'none';
|
|
260
|
+
const sx = (layer.center?.x ?? 0.5) * cw;
|
|
261
|
+
const sy = (layer.center?.y ?? 0.5) * chh;
|
|
262
|
+
const sr = (len(layer.size, ctx.canvas, '30vmin') / cell);
|
|
263
|
+
const gamma = layer.gamma ?? 1;
|
|
264
|
+
const [lo, hi] = layer.range ?? [0, 1];
|
|
265
|
+
|
|
266
|
+
/** The field's value at a cell. */
|
|
267
|
+
const field = (x, y) => {
|
|
268
|
+
const u = x / cw;
|
|
269
|
+
const v = y / chh;
|
|
270
|
+
let f;
|
|
271
|
+
if (source === 'linear') f = 0.5 + ((u - 0.5) * Math.sin(angle) - (v - 0.5) * Math.cos(angle)) * 1.1;
|
|
272
|
+
else if (source === 'radial') f = 1 - Math.hypot(u - (layer.center?.x ?? 0.5), (v - (layer.center?.y ?? 0.5)) * (h / w)) * 1.4;
|
|
273
|
+
else f = noise(u * scale * (w / Math.max(w, h)), v * scale * (h / Math.max(w, h)));
|
|
274
|
+
if (shape !== 'none') {
|
|
275
|
+
const dx = (x - sx) / sr;
|
|
276
|
+
const dy = (y - sy) / sr;
|
|
277
|
+
const inside = shape === 'arch' ? Math.abs(dx) <= 1 && (dy >= 0 ? dy <= 1.3 : dx * dx + dy * dy <= 1) : dx * dx + dy * dy <= 1;
|
|
278
|
+
if (inside) {
|
|
279
|
+
if (shape === 'sphere') {
|
|
280
|
+
// Lambert shading lit from the upper left.
|
|
281
|
+
const dz = Math.sqrt(Math.max(0, 1 - dx * dx - dy * dy));
|
|
282
|
+
f = Math.max(0, (-dx * 0.45 - dy * 0.55 + dz * 0.7)) * 1.05;
|
|
283
|
+
} else f = 0.94;
|
|
284
|
+
} else f *= 0.55;
|
|
285
|
+
}
|
|
286
|
+
return lo + (hi - lo) * Math.max(0, Math.min(1, f)) ** gamma;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** The threshold at a cell. */
|
|
290
|
+
const threshold = (x, y) => {
|
|
291
|
+
if (matrix === 'bayer8') return BAYER8[(y % 8) * 8 + (x % 8)];
|
|
292
|
+
if (matrix === 'noise') {
|
|
293
|
+
// Interleaved gradient noise: cheap, evenly spread, blue-ish.
|
|
294
|
+
const v = 52.9829189 * ((0.06711056 * x + 0.00583715 * y) % 1);
|
|
295
|
+
return v - Math.floor(v);
|
|
296
|
+
}
|
|
297
|
+
return BAYER4[(y % 4) * 4 + (x % 4)];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const px = new Uint8Array(cw * chh * 3);
|
|
301
|
+
const bands = colors.length - 1;
|
|
302
|
+
for (let y = 0; y < chh; y++) {
|
|
303
|
+
for (let x = 0; x < cw; x++) {
|
|
304
|
+
const v = field(x, y) * bands;
|
|
305
|
+
const band = Math.min(bands - 1, Math.floor(v));
|
|
306
|
+
const frac = v - band;
|
|
307
|
+
const c = rgb[frac > threshold(x, y) ? band + 1 : band];
|
|
308
|
+
const i = (y * cw + x) * 3;
|
|
309
|
+
px[i] = c[0]; px[i + 1] = c[1]; px[i + 2] = c[2];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const body = `<image${attrs({ x: 0, y: 0, width: cw * cell, height: chh * cell, preserveAspectRatio: 'none', 'image-rendering': 'optimizeSpeed', style: 'image-rendering:pixelated', href: pngUri(encodePng(px, cw, chh, 3)) })}/>`;
|
|
313
|
+
return { defs: '', body: wrap(layer, body) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// contour
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `contour` — topographic isolines of seeded noise: marching squares at
|
|
322
|
+
* evenly spaced levels, segments joined into lines and smoothed (Chaikin),
|
|
323
|
+
* one level highlighted in the accent, every Nth an index contour.
|
|
324
|
+
*
|
|
325
|
+
* @param {import('./index.js').ContourLayer} layer
|
|
326
|
+
* @param {import('./layers.js').RenderContext} ctx
|
|
327
|
+
* @returns {import('./layers.js').Markup}
|
|
328
|
+
*/
|
|
329
|
+
export function contour(layer, ctx) {
|
|
330
|
+
const { width: w, height: h, rng, palette: p } = ctx;
|
|
331
|
+
const levels = Math.max(2, Math.min(60, layer.levels ?? 20));
|
|
332
|
+
const step = Math.min(w, h) / (layer.resolution ?? 110);
|
|
333
|
+
const gx = Math.ceil(w / step) + 1;
|
|
334
|
+
const gy = Math.ceil(h / step) + 1;
|
|
335
|
+
const noise = valueNoise(rng, layer.octaves ?? 3);
|
|
336
|
+
const scale = layer.scale ?? 2.4;
|
|
337
|
+
const m = Math.max(w, h);
|
|
338
|
+
const vals = new Float64Array(gx * gy);
|
|
339
|
+
let vmin = Infinity;
|
|
340
|
+
let vmax = -Infinity;
|
|
341
|
+
for (let j = 0; j < gy; j++) {
|
|
342
|
+
for (let i = 0; i < gx; i++) {
|
|
343
|
+
const v = noise(((i * step) / m) * scale, ((j * step) / m) * scale);
|
|
344
|
+
vals[j * gx + i] = v;
|
|
345
|
+
vmin = Math.min(vmin, v);
|
|
346
|
+
vmax = Math.max(vmax, v);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const at = (i, j) => vals[j * gx + i];
|
|
350
|
+
const lineColor = col(layer.color, p.text);
|
|
351
|
+
const stroke = len(layer.stroke, ctx.canvas, '0.14vmin');
|
|
352
|
+
const every = layer.index ?? 5;
|
|
353
|
+
const hl = layer.highlight === false ? -1 : typeof layer.highlight === 'number' ? layer.highlight : rng.int(Math.floor(levels * 0.3), Math.floor(levels * 0.7));
|
|
354
|
+
|
|
355
|
+
let normal = '';
|
|
356
|
+
let index = '';
|
|
357
|
+
let accent = '';
|
|
358
|
+
for (let k = 1; k <= levels; k++) {
|
|
359
|
+
const t = vmin + ((vmax - vmin) * k) / (levels + 1);
|
|
360
|
+
const segs = [];
|
|
361
|
+
for (let j = 0; j < gy - 1; j++) {
|
|
362
|
+
for (let i = 0; i < gx - 1; i++) {
|
|
363
|
+
const a = at(i, j);
|
|
364
|
+
const b = at(i + 1, j);
|
|
365
|
+
const c = at(i + 1, j + 1);
|
|
366
|
+
const d = at(i, j + 1);
|
|
367
|
+
const code = (a > t ? 8 : 0) | (b > t ? 4 : 0) | (c > t ? 2 : 0) | (d > t ? 1 : 0);
|
|
368
|
+
if (code === 0 || code === 15) continue;
|
|
369
|
+
const x0 = i * step;
|
|
370
|
+
const y0 = j * step;
|
|
371
|
+
const lerp = (v1, v2) => (t - v1) / (v2 - v1);
|
|
372
|
+
const top = [x0 + lerp(a, b) * step, y0];
|
|
373
|
+
const right = [x0 + step, y0 + lerp(b, c) * step];
|
|
374
|
+
const bottom = [x0 + lerp(d, c) * step, y0 + step];
|
|
375
|
+
const left = [x0, y0 + lerp(a, d) * step];
|
|
376
|
+
const E = { 1: [[left, bottom]], 2: [[bottom, right]], 3: [[left, right]], 4: [[top, right]], 5: [[left, top], [bottom, right]], 6: [[top, bottom]], 7: [[left, top]], 8: [[left, top]], 9: [[top, bottom]], 10: [[left, bottom], [top, right]], 11: [[top, right]], 12: [[left, right]], 13: [[bottom, right]], 14: [[left, bottom]] }[code];
|
|
377
|
+
for (const s of E) segs.push(s);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const d = chains(segs).map((line) => smoothPath(line)).join('');
|
|
381
|
+
if (k === hl) accent += d;
|
|
382
|
+
else if (every > 0 && k % every === 0) index += d;
|
|
383
|
+
else normal += d;
|
|
384
|
+
}
|
|
385
|
+
const opacity = layer.opacity ?? 0.5;
|
|
386
|
+
let body = `<path${attrs({ d: normal, fill: 'none', stroke: lineColor, 'stroke-width': stroke, opacity, 'stroke-linejoin': 'round' })}/>`;
|
|
387
|
+
if (index) body += `<path${attrs({ d: index, fill: 'none', stroke: lineColor, 'stroke-width': stroke * (layer.indexWeight ?? 2.2), opacity: Math.min(1, opacity * 1.3), 'stroke-linejoin': 'round' })}/>`;
|
|
388
|
+
if (accent) body += `<path${attrs({ d: accent, fill: 'none', stroke: col(layer.highlightColor, p.accent), 'stroke-width': stroke * 2.4, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })}/>`;
|
|
389
|
+
return { defs: '', body: wrap({ ...layer, opacity: undefined }, body) };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Join marching-squares segments into polylines by shared endpoints.
|
|
394
|
+
*
|
|
395
|
+
* @param {number[][][]} segs - `[[x, y], [x, y]]` pairs.
|
|
396
|
+
* @returns {number[][][]} Polylines.
|
|
397
|
+
*/
|
|
398
|
+
function chains(segs) {
|
|
399
|
+
const key = (/** @type {number[]} */ p) => `${Math.round(p[0] * 100)},${Math.round(p[1] * 100)}`;
|
|
400
|
+
/** @type {Map<string, number[]>} */
|
|
401
|
+
const ends = new Map();
|
|
402
|
+
segs.forEach((s, i) => {
|
|
403
|
+
for (const p of s) {
|
|
404
|
+
const k = key(p);
|
|
405
|
+
const list = ends.get(k);
|
|
406
|
+
if (list) list.push(i); else ends.set(k, [i]);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
const used = new Uint8Array(segs.length);
|
|
410
|
+
const out = [];
|
|
411
|
+
for (let i = 0; i < segs.length; i++) {
|
|
412
|
+
if (used[i]) continue;
|
|
413
|
+
used[i] = 1;
|
|
414
|
+
const line = [segs[i][0], segs[i][1]];
|
|
415
|
+
for (const dir of [1, 0]) {
|
|
416
|
+
for (;;) {
|
|
417
|
+
const tip = dir ? line[line.length - 1] : line[0];
|
|
418
|
+
const next = (ends.get(key(tip)) ?? []).find((n) => !used[n]);
|
|
419
|
+
if (next === undefined) break;
|
|
420
|
+
used[next] = 1;
|
|
421
|
+
const [p0, p1] = segs[next];
|
|
422
|
+
const other = key(p0) === key(tip) ? p1 : p0;
|
|
423
|
+
if (dir) line.push(other); else line.unshift(other);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (line.length > 2) out.push(line);
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Smooth a polyline (two rounds of Chaikin) to path data.
|
|
433
|
+
*
|
|
434
|
+
* @param {number[][]} pts
|
|
435
|
+
* @returns {string}
|
|
436
|
+
*/
|
|
437
|
+
function smoothPath(pts) {
|
|
438
|
+
let p = pts;
|
|
439
|
+
const closed = Math.hypot(p[0][0] - p[p.length - 1][0], p[0][1] - p[p.length - 1][1]) < 0.01;
|
|
440
|
+
for (let r = 0; r < 2; r++) {
|
|
441
|
+
const q = closed ? [] : [p[0]];
|
|
442
|
+
for (let i = 0; i < p.length - 1; i++) {
|
|
443
|
+
const [a, b] = [p[i], p[i + 1]];
|
|
444
|
+
q.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25], [a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
|
|
445
|
+
}
|
|
446
|
+
if (!closed) q.push(p[p.length - 1]); else q.push(q[0]);
|
|
447
|
+
p = q;
|
|
448
|
+
}
|
|
449
|
+
return `M${p.map((pt) => `${num(pt[0], 1)} ${num(pt[1], 1)}`).join('L')}${closed ? 'Z' : ''}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// waveform
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Seeded demo peaks: a smooth loudness envelope with transients. Not audio:
|
|
458
|
+
* pass `peaks` or `audio` for a real episode.
|
|
459
|
+
*
|
|
460
|
+
* @param {import('./rng.js').Rng} rng
|
|
461
|
+
* @param {number} n
|
|
462
|
+
* @returns {number[]}
|
|
463
|
+
*/
|
|
464
|
+
export function demoPeaks(rng, n) {
|
|
465
|
+
const env = valueNoise(rng, 2);
|
|
466
|
+
return Array.from({ length: n }, (_, i) => {
|
|
467
|
+
const t = i / n;
|
|
468
|
+
const fade = Math.min(1, t * 18, (1 - t) * 18);
|
|
469
|
+
return Math.max(0.04, Math.min(1, (0.25 + env(t * 6, 0.5) * 0.8) * (0.55 + rng.next() * 0.45) * fade));
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Resample peaks to `n` values (max over each bucket).
|
|
475
|
+
*
|
|
476
|
+
* @param {number[]} peaks
|
|
477
|
+
* @param {number} n
|
|
478
|
+
* @returns {number[]}
|
|
479
|
+
*/
|
|
480
|
+
function resample(peaks, n) {
|
|
481
|
+
if (peaks.length === n) return peaks.slice();
|
|
482
|
+
return Array.from({ length: n }, (_, i) => {
|
|
483
|
+
const a = Math.floor((i * peaks.length) / n);
|
|
484
|
+
const b = Math.max(a + 1, Math.floor(((i + 1) * peaks.length) / n));
|
|
485
|
+
let m = 0;
|
|
486
|
+
for (let k = a; k < b; k++) m = Math.max(m, Math.abs(peaks[k] ?? 0));
|
|
487
|
+
return m;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @param {number} s @returns {string} `m:ss` or `h:mm:ss`. */
|
|
492
|
+
export function formatDuration(s) {
|
|
493
|
+
const t = Math.round(s);
|
|
494
|
+
const hh = Math.floor(t / 3600);
|
|
495
|
+
const mm = Math.floor((t % 3600) / 60);
|
|
496
|
+
const ss = String(t % 60).padStart(2, '0');
|
|
497
|
+
return hh ? `${hh}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* `waveform` — an episode's waveform: `peaks` you pass, or `audio` decoded at
|
|
502
|
+
* build with `@arraypress/waveform-gen` (an optional peer). Styles: bars,
|
|
503
|
+
* mirrored bars, a line, or dots. With neither, seeded demo peaks.
|
|
504
|
+
*
|
|
505
|
+
* @param {import('./index.js').WaveformLayer} layer
|
|
506
|
+
* @param {import('./layers.js').RenderContext} ctx
|
|
507
|
+
* @returns {Promise<import('./layers.js').Markup>}
|
|
508
|
+
*/
|
|
509
|
+
export async function waveform(layer, ctx) {
|
|
510
|
+
const { rng, palette: p } = ctx;
|
|
511
|
+
const r = regionPx(layer.region ?? { x: 0.07, y: 0.3, width: 0.86, height: 0.4 }, ctx.canvas);
|
|
512
|
+
const count = Math.max(8, layer.bars ?? 88);
|
|
513
|
+
let peaks = layer.peaks;
|
|
514
|
+
let duration = layer.duration;
|
|
515
|
+
if (!peaks && layer.audio) {
|
|
516
|
+
let gen;
|
|
517
|
+
try {
|
|
518
|
+
gen = await import('@arraypress/waveform-gen');
|
|
519
|
+
} catch {
|
|
520
|
+
throw new Error('Decoding audio needs @arraypress/waveform-gen: npm install @arraypress/waveform-gen (or pass peaks)');
|
|
521
|
+
}
|
|
522
|
+
const res = await gen.generatePeaks(layer.audio, { samples: Math.max(count * 4, 400) });
|
|
523
|
+
peaks = res.peaks;
|
|
524
|
+
duration ??= res.duration;
|
|
525
|
+
}
|
|
526
|
+
const values = resample(peaks?.length ? peaks.map(Number) : demoPeaks(rng, count), count);
|
|
527
|
+
const top = Math.max(...values, 1e-6);
|
|
528
|
+
const v = values.map((x) => x / top);
|
|
529
|
+
const color = col(layer.color, p.accent);
|
|
530
|
+
const style = layer.style ?? 'mirrored';
|
|
531
|
+
const pitch = r.width / count;
|
|
532
|
+
const bw = pitch * (1 - (layer.gap ?? 0.4));
|
|
533
|
+
const mid = r.y + r.height / 2;
|
|
534
|
+
const rx = layer.round === false ? 0 : bw / 2;
|
|
535
|
+
let body = '';
|
|
536
|
+
if (style === 'bars' || style === 'mirrored') {
|
|
537
|
+
const rects = v.map((a, i) => {
|
|
538
|
+
const x = r.x + i * pitch + (pitch - bw) / 2;
|
|
539
|
+
const hh = Math.max(bw, a * (style === 'mirrored' ? r.height : r.height));
|
|
540
|
+
const y = style === 'mirrored' ? mid - hh / 2 : r.y + r.height - hh;
|
|
541
|
+
return `<rect${attrs({ x, y, width: bw, height: hh, rx: Math.min(rx, hh / 2) || undefined })}/>`;
|
|
542
|
+
});
|
|
543
|
+
body = `<g fill="${color}">${rects.join('')}</g>`;
|
|
544
|
+
} else if (style === 'dots') {
|
|
545
|
+
const rr = bw / 2;
|
|
546
|
+
const dots = [];
|
|
547
|
+
v.forEach((a, i) => {
|
|
548
|
+
const x = r.x + i * pitch + pitch / 2;
|
|
549
|
+
const n = Math.max(1, Math.round((a * r.height) / (rr * 2.6)));
|
|
550
|
+
for (let k = 0; k < n; k++) {
|
|
551
|
+
const off = (k - (n - 1) / 2) * rr * 2.6;
|
|
552
|
+
dots.push(`<circle${attrs({ cx: x, cy: mid + off, r: rr })}/>`);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
body = `<g fill="${color}">${dots.join('')}</g>`;
|
|
556
|
+
} else if (style === 'line') {
|
|
557
|
+
const pts = v.map((a, i) => [r.x + i * pitch + pitch / 2, mid - (a * r.height) / 2]);
|
|
558
|
+
const low = v.map((a, i) => [r.x + i * pitch + pitch / 2, mid + (a * r.height) / 2]).reverse();
|
|
559
|
+
const d = `M${[...pts, ...low].map(([x, y]) => `${num(x)} ${num(y)}`).join('L')}Z`;
|
|
560
|
+
body = `<path${attrs({ d, fill: color, opacity: 0.18 })}/><path${attrs({ d: `M${pts.map(([x, y]) => `${num(x)} ${num(y)}`).join('L')}`, fill: 'none', stroke: color, 'stroke-width': len(layer.stroke, ctx.canvas, '0.35vmin'), 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })}/>`;
|
|
561
|
+
} else {
|
|
562
|
+
throw new TypeError(`Unknown waveform style "${style}" (bars, mirrored, line, dots)`);
|
|
563
|
+
}
|
|
564
|
+
// A caption under the wave: the duration (when known) or your text.
|
|
565
|
+
const caption = layer.caption === true ? (typeof duration === 'number' ? formatDuration(duration) : duration ?? undefined) : layer.caption;
|
|
566
|
+
if (caption) {
|
|
567
|
+
const t = await textLayer(/** @type {any} */ ({ type: 'text', content: caption, size: len(layer.captionSize, ctx.canvas, '3vmin'), weight: 500, letterSpacing: 0.02, color: col(layer.captionColor, p.text), anchor: 'top-left', position: { x: r.x / ctx.width, y: (r.y + r.height) / ctx.height + 0.04 }, maxLines: 1, balance: false }), ctx);
|
|
568
|
+
body += t.body;
|
|
569
|
+
}
|
|
570
|
+
return { defs: '', body: wrap(layer, body) };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// tiles (bauhaus)
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* `tiles` — a Bauhaus grid: each cell a ground colour with one shape
|
|
579
|
+
* (quarter circle, half circle, circle, square, triangle) in another,
|
|
580
|
+
* rotated by quarters. `series` seeds the grid's rhythm (cell counts and a
|
|
581
|
+
* large merged cell), so a series of covers shares a family look while the
|
|
582
|
+
* cover's own seed picks each cell.
|
|
583
|
+
*
|
|
584
|
+
* @param {import('./index.js').TilesLayer} layer
|
|
585
|
+
* @param {import('./layers.js').RenderContext} ctx
|
|
586
|
+
* @returns {import('./layers.js').Markup}
|
|
587
|
+
*/
|
|
588
|
+
export function tiles(layer, ctx) {
|
|
589
|
+
const { rng, palette: p } = ctx;
|
|
590
|
+
const r = regionPx(layer.region, ctx.canvas);
|
|
591
|
+
// The series seeds the rhythm; without one, each cover has its own.
|
|
592
|
+
const series = layer.series != null ? createRng(`series:${layer.series}`) : rng;
|
|
593
|
+
const cols = layer.cols ?? series.int(2, 4);
|
|
594
|
+
const rows = layer.rows ?? Math.max(1, Math.round((cols * r.height) / r.width));
|
|
595
|
+
const big = layer.merge === false || cols < 3 || rows < 2 ? null : { c: series.int(0, cols - 2), r: series.int(0, rows - 2) };
|
|
596
|
+
const kindsFor = series.shuffle(['quarter', 'half', 'circle', 'square', 'triangle', 'quarter', 'half']);
|
|
597
|
+
const colors = (layer.colors?.length ? layer.colors : [p.accent, ...p.colors.slice(1, 3), p.text]).map((c) => toHex(c));
|
|
598
|
+
const ground = col(layer.ground, p.bg);
|
|
599
|
+
const all = [ground, ...colors];
|
|
600
|
+
const cw = r.width / cols;
|
|
601
|
+
const chh = r.height / rows;
|
|
602
|
+
let body = '';
|
|
603
|
+
/** One cell. */
|
|
604
|
+
const cell = (x, y, w, h, k) => {
|
|
605
|
+
const kind = layer.kinds?.length ? rng.pick(layer.kinds) : kindsFor[(k + rng.int(0, 2)) % kindsFor.length];
|
|
606
|
+
const bgI = rng.int(0, all.length - 1);
|
|
607
|
+
let fgI = rng.int(0, all.length - 2);
|
|
608
|
+
if (fgI >= bgI) fgI++;
|
|
609
|
+
const bg = all[bgI];
|
|
610
|
+
const fg = all[fgI];
|
|
611
|
+
const rot = rng.int(0, 3) * 90;
|
|
612
|
+
const s = Math.min(w, h);
|
|
613
|
+
const cx = x + w / 2;
|
|
614
|
+
const cy = y + h / 2;
|
|
615
|
+
let shape = '';
|
|
616
|
+
const t = `rotate(${rot} ${num(cx)} ${num(cy)})`;
|
|
617
|
+
if (kind === 'quarter') shape = `<path${attrs({ d: `M${num(cx - s / 2)} ${num(cy + s / 2)}V${num(cy - s / 2)}A${num(s)} ${num(s)} 0 0 1 ${num(cx + s / 2)} ${num(cy + s / 2)}Z`, fill: fg, transform: t })}/>`;
|
|
618
|
+
else if (kind === 'half') shape = `<path${attrs({ d: `M${num(cx - s / 2)} ${num(cy + s / 2)}A${num(s / 2)} ${num(s / 2)} 0 0 1 ${num(cx + s / 2)} ${num(cy + s / 2)}Z`, fill: fg, transform: t })}/>`;
|
|
619
|
+
else if (kind === 'circle') shape = `<circle${attrs({ cx, cy, r: s * 0.36, fill: fg })}/>`;
|
|
620
|
+
else if (kind === 'square') shape = `<rect${attrs({ x: cx - s * 0.3, y: cy - s * 0.3, width: s * 0.6, height: s * 0.6, fill: fg, transform: t })}/>`;
|
|
621
|
+
else shape = `<path${attrs({ d: `M${num(cx - s / 2)} ${num(cy + s / 2)}L${num(cx + s / 2)} ${num(cy + s / 2)}L${num(cx - s / 2)} ${num(cy - s / 2)}Z`, fill: fg, transform: t })}/>`;
|
|
622
|
+
return `<rect${attrs({ x, y, width: w, height: h, fill: bg })}/>${shape}`;
|
|
623
|
+
};
|
|
624
|
+
let k = 0;
|
|
625
|
+
for (let j = 0; j < rows; j++) {
|
|
626
|
+
for (let i = 0; i < cols; i++) {
|
|
627
|
+
if (big && i >= big.c && i <= big.c + 1 && j >= big.r && j <= big.r + 1) {
|
|
628
|
+
if (i === big.c && j === big.r) body += cell(r.x + i * cw, r.y + j * chh, cw * 2, chh * 2, k++);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
body += cell(r.x + i * cw, r.y + j * chh, cw, chh, k++);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const id = `${ctx.id}c`;
|
|
635
|
+
return { defs: `<clipPath id="${id}"><rect${attrs(r)}/></clipPath>`, body: wrap(layer, `<g clip-path="url(#${id})">${body}</g>`) };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
// rule
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* `rule` — a hairline from one point to another (fractions of the canvas).
|
|
644
|
+
*
|
|
645
|
+
* @param {import('./index.js').RuleLayer} layer
|
|
646
|
+
* @param {import('./layers.js').RenderContext} ctx
|
|
647
|
+
* @returns {import('./layers.js').Markup}
|
|
648
|
+
*/
|
|
649
|
+
export function rule(layer, ctx) {
|
|
650
|
+
const { width: w, height: h } = ctx;
|
|
651
|
+
const inset = len(layer.inset, ctx.canvas, Math.sqrt(w * h) * 0.07);
|
|
652
|
+
const y = (layer.y ?? 0.15) * h;
|
|
653
|
+
const from = layer.from ? [layer.from.x * w, layer.from.y * h] : [inset, y];
|
|
654
|
+
const to = layer.to ? [layer.to.x * w, layer.to.y * h] : [w - inset, y];
|
|
655
|
+
const body = `<path${attrs({ d: `M${num(from[0])} ${num(from[1])}L${num(to[0])} ${num(to[1])}`, stroke: col(layer.color, ctx.palette.text), 'stroke-width': len(layer.stroke, ctx.canvas, '0.12vmin'), fill: 'none' })}/>`;
|
|
656
|
+
return { defs: '', body: wrap(layer, body) };
|
|
657
|
+
}
|
|
658
|
+
|