@kolkrabbi/kol-growth 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +28 -2
- package/package.json +33 -3
- package/src/engine.js +200 -0
- package/src/index.js +3 -0
- package/src/rng.js +13 -0
- package/src/selftest.mjs +20 -0
- package/src/shapes.js +40 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @kolkrabbi/kol-growth
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
- **First release — the engine tier's fourth package** (ARCHITECTURE §3, GrowthLineStage from kol-atlas). kol-fxr's
|
|
6
|
+
growing line moved in so fxr and every other app grow the same line from one engine.
|
|
7
|
+
- `Growth` · `densityFrom` · `SIM_W` — kol-fxr `src/loops/growth/engine.js`, carried verbatim.
|
|
8
|
+
- `mulberry32` — kol-fxr `src/loops/gl/rng.js`, verbatim.
|
|
9
|
+
- `orbCanvas` (fxr's `sourceCanvas('orb')`, verbatim) and `pathCanvas` (new: any SVG path `d` as a density source).
|
|
10
|
+
- `src/selftest.mjs` — grows to the cap, a seed repeats, `poke` moves the line.
|
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kolkrabbi/kol-growth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The growing line: differential growth — one closed line that grows, folds and never crosses itself, packing tighter
|
|
4
|
+
where a density field is darker. Plain ESM, no React, no dependencies. Engine tier (ARCHITECTURE §3): the stage that
|
|
5
|
+
draws it on a page is kol-component's `GrowthLineStage`; kol-fxr's generator and effect run it in their own loops.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Growth, densityFrom, mulberry32, orbCanvas, pathCanvas } from '@kolkrabbi/kol-growth'
|
|
9
|
+
|
|
10
|
+
const sim = new Growth({ w, h, density: densityFrom(pathCanvas(d, w / h, 0.6)), rand: mulberry32(7), cap: 14000 })
|
|
11
|
+
sim.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
|
|
12
|
+
sim.seed() // a small ring at the field's centre of darkness
|
|
13
|
+
sim.step() // one step: smooth, repel, resample, grow — call per frame until x.length hits cap
|
|
14
|
+
sim.poke(px, py, down) // the pointer as a force: a push, or a blast when down
|
|
15
|
+
sim.draw(ctx, w, h, { ink, width: 1 }) // the one path onto a canvas
|
|
16
|
+
sim.pathD(w, h) // the same path as an SVG `d`
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| export | does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `Growth` | the sim — `step`, `seed`, `poke`, `draw`, `pathD`, `setParams({ minGap, maxGap, contrast, growth, cap })` |
|
|
22
|
+
| `densityFrom(canvas, cols?, invert?)` | 1 − luma on a grid, lightly blurred — the field the spacing reads |
|
|
23
|
+
| `SIM_W` | 1000, the sim width kol-fxr's loops use |
|
|
24
|
+
| `mulberry32(seed)` | the seeded 0..1 PRNG; pass as `rand` and the same seed grows the same line |
|
|
25
|
+
| `orbCanvas(aspect)` | the built-in orb, dark at the centre |
|
|
26
|
+
| `pathCanvas(d, aspect, fit?)` | any SVG path `d`, solid black on white, centred at `fit` of the frame |
|
|
27
|
+
|
|
28
|
+
`densityFrom`, `orbCanvas` and `pathCanvas` draw on a canvas, so they need a browser; the sim itself runs anywhere
|
|
29
|
+
(`pnpm --filter @kolkrabbi/kol-growth test`). Proved in `apps/growth`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-growth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The growing line engine, differential growth into shapes",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"module": "./src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kol",
|
|
19
|
+
"kolkrabbi",
|
|
20
|
+
"design-system",
|
|
21
|
+
"generative",
|
|
22
|
+
"differential-growth",
|
|
23
|
+
"engine"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
|
|
28
|
+
"directory": "packages/growth"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node src/selftest.mjs"
|
|
35
|
+
}
|
|
6
36
|
}
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The growing line (plan 43 W4) — differential growth: ONE closed line that grows, folds and never crosses itself,
|
|
2
|
+
// packing tighter where a density field is darker. The reference is algorigraph's plotted portraits (user 2026-10-28:
|
|
3
|
+
// *"a single elastic loop morphs into the shapes of a…"*). The generator (growthLine.js) feeds it a built-in image;
|
|
4
|
+
// the effect (filters/fxGrowth.js) feeds it the layer's own photo. Both draw it the same way: one path, ink on paper.
|
|
5
|
+
//
|
|
6
|
+
// A step: every node is pulled toward its neighbours' midpoint (smooth), pushed off every non-adjacent node closer than
|
|
7
|
+
// the local spacing (no crossings, even gaps), held inside the frame; edges longer than half the spacing split, shorter
|
|
8
|
+
// than a fifth merge, and `growth` random edges split per step so the line keeps lengthening until the node cap.
|
|
9
|
+
// Spacing is local: `minGap` where the density is 1, `maxGap` where it is 0. Units are sim px — the sim is 1000 wide
|
|
10
|
+
// and as tall as the frame's aspect, scaled to the frame at draw.
|
|
11
|
+
|
|
12
|
+
export const SIM_W = 1000
|
|
13
|
+
|
|
14
|
+
/* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
|
|
15
|
+
export function densityFrom(canvas, cols = 160, invert = false) {
|
|
16
|
+
const rows = Math.max(1, Math.round(cols * canvas.height / canvas.width))
|
|
17
|
+
const c = document.createElement('canvas')
|
|
18
|
+
c.width = cols; c.height = rows
|
|
19
|
+
const g = c.getContext('2d', { willReadFrequently: true })
|
|
20
|
+
g.filter = 'blur(1px)'
|
|
21
|
+
g.drawImage(canvas, 0, 0, cols, rows)
|
|
22
|
+
const d = g.getImageData(0, 0, cols, rows).data
|
|
23
|
+
const out = new Float32Array(cols * rows)
|
|
24
|
+
for (let i = 0; i < out.length; i++) {
|
|
25
|
+
const j = i * 4
|
|
26
|
+
const a = d[j + 3] / 255
|
|
27
|
+
/* transparent reads as paper */
|
|
28
|
+
const lum = ((0.2126 * d[j] + 0.7152 * d[j + 1] + 0.0722 * d[j + 2]) / 255) * a + (1 - a)
|
|
29
|
+
out[i] = invert ? lum : 1 - lum
|
|
30
|
+
}
|
|
31
|
+
return { cols, rows, data: out }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Growth {
|
|
35
|
+
constructor({ w = SIM_W, h = SIM_W, density = null, rand = Math.random, cap = 6000 }) {
|
|
36
|
+
this.w = w; this.h = h
|
|
37
|
+
this.density = density
|
|
38
|
+
this.rand = rand
|
|
39
|
+
this.cap = cap
|
|
40
|
+
this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4
|
|
41
|
+
this.x = []; this.y = []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setParams({ minGap, maxGap, contrast, growth, cap }) {
|
|
45
|
+
if (minGap != null) this.minGap = minGap
|
|
46
|
+
if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
|
|
47
|
+
if (contrast != null) this.contrast = contrast
|
|
48
|
+
if (growth != null) this.growth = growth
|
|
49
|
+
if (cap != null) this.cap = cap
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* a small ring of nodes — at (cx, cy) in 0..1, or at the field's centre of darkness */
|
|
53
|
+
seed(cx, cy, r = 14) {
|
|
54
|
+
if (cx == null && this.density) {
|
|
55
|
+
const { cols, rows, data } = this.density
|
|
56
|
+
let sx = 0, sy = 0, sw = 0
|
|
57
|
+
for (let i = 0; i < data.length; i++) { const v = data[i] * data[i]; sx += v * (i % cols); sy += v * Math.floor(i / cols); sw += v }
|
|
58
|
+
if (sw > 0) { cx = (sx / sw + 0.5) / cols; cy = (sy / sw + 0.5) / rows }
|
|
59
|
+
}
|
|
60
|
+
cx = (cx ?? 0.5) * this.w; cy = (cy ?? 0.5) * this.h
|
|
61
|
+
const n = 24
|
|
62
|
+
this.x = []; this.y = []
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
const a = (i / n) * Math.PI * 2
|
|
65
|
+
this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
dens(x, y) {
|
|
70
|
+
const D = this.density
|
|
71
|
+
if (!D) return 0.5
|
|
72
|
+
const c = Math.min(D.cols - 1, Math.max(0, Math.floor((x / this.w) * D.cols)))
|
|
73
|
+
const r = Math.min(D.rows - 1, Math.max(0, Math.floor((y / this.h) * D.rows)))
|
|
74
|
+
return D.data[r * D.cols + c]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
gap(x, y) {
|
|
78
|
+
const d = Math.pow(Math.min(1, Math.max(0, this.dens(x, y))), 1 / this.contrast)
|
|
79
|
+
return this.maxGap + (this.minGap - this.maxGap) * d
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
step() {
|
|
83
|
+
const { x, y, w, h } = this
|
|
84
|
+
let n = x.length
|
|
85
|
+
if (n < 3) return
|
|
86
|
+
const R = this.maxGap
|
|
87
|
+
/* spatial hash on the largest gap */
|
|
88
|
+
const cols = Math.ceil(w / R) + 1, rows = Math.ceil(h / R) + 1
|
|
89
|
+
const head = new Int32Array(cols * rows).fill(-1)
|
|
90
|
+
const next = new Int32Array(n)
|
|
91
|
+
const cell = (px, py) => Math.min(rows - 1, Math.max(0, Math.floor(py / R))) * cols + Math.min(cols - 1, Math.max(0, Math.floor(px / R)))
|
|
92
|
+
for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
|
|
93
|
+
const gaps = new Float32Array(n)
|
|
94
|
+
for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
|
|
95
|
+
const fx = new Float32Array(n), fy = new Float32Array(n)
|
|
96
|
+
for (let i = 0; i < n; i++) {
|
|
97
|
+
const ip = (i - 1 + n) % n, inx = (i + 1) % n
|
|
98
|
+
/* smooth: toward the neighbours' midpoint */
|
|
99
|
+
fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
|
|
100
|
+
fy[i] += ((y[ip] + y[inx]) / 2 - y[i]) * 0.35
|
|
101
|
+
/* repel: every non-adjacent node inside the local gap */
|
|
102
|
+
const gi = gaps[i]
|
|
103
|
+
const cx = Math.floor(x[i] / R), cy = Math.floor(y[i] / R)
|
|
104
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
105
|
+
const ry = cy + oy
|
|
106
|
+
if (ry < 0 || ry >= rows) continue
|
|
107
|
+
for (let ox = -1; ox <= 1; ox++) {
|
|
108
|
+
const rx = cx + ox
|
|
109
|
+
if (rx < 0 || rx >= cols) continue
|
|
110
|
+
for (let j = head[ry * cols + rx]; j !== -1; j = next[j]) {
|
|
111
|
+
if (j === i || j === ip || j === inx) continue
|
|
112
|
+
const dx = x[i] - x[j], dy = y[i] - y[j]
|
|
113
|
+
const g = (gi + gaps[j]) / 2
|
|
114
|
+
const d2 = dx * dx + dy * dy
|
|
115
|
+
if (d2 >= g * g || d2 === 0) continue
|
|
116
|
+
const d = Math.sqrt(d2)
|
|
117
|
+
const push = ((g - d) / g) * 0.5
|
|
118
|
+
fx[i] += (dx / d) * push * g * 0.5
|
|
119
|
+
fy[i] += (dy / d) * push * g * 0.5
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const m = 2
|
|
125
|
+
for (let i = 0; i < n; i++) {
|
|
126
|
+
const lim = gaps[i] * 0.25
|
|
127
|
+
x[i] = Math.min(w - m, Math.max(m, x[i] + Math.max(-lim, Math.min(lim, fx[i]))))
|
|
128
|
+
y[i] = Math.min(h - m, Math.max(m, y[i] + Math.max(-lim, Math.min(lim, fy[i]))))
|
|
129
|
+
}
|
|
130
|
+
/* resample: split long edges, merge short ones, then grow */
|
|
131
|
+
const nx = [], ny = []
|
|
132
|
+
let budget = this.cap - n
|
|
133
|
+
for (let i = 0; i < n; i++) {
|
|
134
|
+
const j = (i + 1) % n
|
|
135
|
+
const g = this.gap(x[i], y[i])
|
|
136
|
+
const dx = x[j] - x[i], dy = y[j] - y[i]
|
|
137
|
+
const len = Math.hypot(dx, dy)
|
|
138
|
+
if (len < g * 0.2 && n > 8) continue
|
|
139
|
+
nx.push(x[i]); ny.push(y[i])
|
|
140
|
+
if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); budget-- }
|
|
141
|
+
}
|
|
142
|
+
for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
|
|
143
|
+
const i = Math.floor(this.rand() * nx.length), j = (i + 1) % nx.length
|
|
144
|
+
/* a split nudged sideways starts a new fold */
|
|
145
|
+
const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
|
|
146
|
+
const s = (this.rand() - 0.5) * 0.6
|
|
147
|
+
nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
|
|
148
|
+
ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
|
|
149
|
+
}
|
|
150
|
+
this.x = nx; this.y = ny
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* THE POINTER AS A FORCE (sim units) — penrose/common.js's verbs on this line: a move pushes the nodes out of a ring
|
|
154
|
+
* around (px, py), a press blasts (3× reach, 6× strength, capped at 3% of the frame a step so it reads as a shove, not
|
|
155
|
+
* a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. */
|
|
156
|
+
poke(px, py, down = false, amount = 1) {
|
|
157
|
+
if (!amount) return
|
|
158
|
+
const { x, y, w, h } = this, s = Math.min(w, h), m = 2
|
|
159
|
+
const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
|
|
160
|
+
const strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
|
|
161
|
+
for (let i = 0; i < x.length; i++) {
|
|
162
|
+
const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
|
|
163
|
+
if (d2 >= r2 || d2 < 1e-6) continue
|
|
164
|
+
const d = Math.sqrt(d2), f = (1 - d / reach) * strength
|
|
165
|
+
x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
|
|
166
|
+
y[i] = Math.min(h - m, Math.max(m, y[i] + (dy / d) * f))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* the one path, scaled onto a w × h frame */
|
|
171
|
+
draw(ctx, fw, fh, { ink = '#111', paper = null, width = 1.2 } = {}) {
|
|
172
|
+
if (paper) { ctx.fillStyle = paper; ctx.fillRect(0, 0, fw, fh) }
|
|
173
|
+
const { x, y } = this
|
|
174
|
+
const n = x.length
|
|
175
|
+
if (n < 3) return
|
|
176
|
+
const sx = fw / this.w, sy = fh / this.h
|
|
177
|
+
ctx.beginPath()
|
|
178
|
+
/* through the edge midpoints with the nodes as control points — a smooth closed curve */
|
|
179
|
+
const mx = (i) => ((x[i] + x[(i + 1) % n]) / 2) * sx, my = (i) => ((y[i] + y[(i + 1) % n]) / 2) * sy
|
|
180
|
+
ctx.moveTo(mx(n - 1), my(n - 1))
|
|
181
|
+
for (let i = 0; i < n; i++) ctx.quadraticCurveTo(x[i] * sx, y[i] * sy, mx(i), my(i))
|
|
182
|
+
ctx.closePath()
|
|
183
|
+
ctx.strokeStyle = ink
|
|
184
|
+
ctx.lineWidth = width
|
|
185
|
+
ctx.lineJoin = 'round'
|
|
186
|
+
ctx.stroke()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* the same path as one SVG `d`, in frame units */
|
|
190
|
+
pathD(fw, fh) {
|
|
191
|
+
const { x, y } = this
|
|
192
|
+
const n = x.length
|
|
193
|
+
if (n < 3) return ''
|
|
194
|
+
const sx = fw / this.w, sy = fh / this.h, f = (v) => v.toFixed(2)
|
|
195
|
+
const mx = (i) => f(((x[i] + x[(i + 1) % n]) / 2) * sx), my = (i) => f(((y[i] + y[(i + 1) % n]) / 2) * sy)
|
|
196
|
+
let d = `M${mx(n - 1)} ${my(n - 1)}`
|
|
197
|
+
for (let i = 0; i < n; i++) d += `Q${f(x[i] * sx)} ${f(y[i] * sy)} ${mx(i)} ${my(i)}`
|
|
198
|
+
return d + 'Z'
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/index.js
ADDED
package/src/rng.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// rng — the seeded PRNG the growing line takes as `rand`, carried verbatim from kol-fxr (src/loops/gl/rng.js).
|
|
2
|
+
|
|
3
|
+
/** Deterministic 0..1 PRNG seeded by an integer. Same seed → same sequence. */
|
|
4
|
+
export function mulberry32(seed) {
|
|
5
|
+
let a = seed >>> 0
|
|
6
|
+
return function () {
|
|
7
|
+
a |= 0
|
|
8
|
+
a = (a + 0x6D2B79F5) | 0
|
|
9
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
10
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
11
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/selftest.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// node src/selftest.mjs — the engine with no density field (no DOM): it grows to the cap, a seed repeats, poke moves nodes.
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Growth, mulberry32 } from './index.js'
|
|
4
|
+
|
|
5
|
+
const grow = (seed) => {
|
|
6
|
+
const g = new Growth({ w: 400, h: 300, rand: mulberry32(seed), cap: 600 })
|
|
7
|
+
g.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
|
|
8
|
+
g.seed()
|
|
9
|
+
for (let i = 0; i < 400 && g.x.length < g.cap; i++) g.step()
|
|
10
|
+
return g
|
|
11
|
+
}
|
|
12
|
+
const a = grow(7), b = grow(7)
|
|
13
|
+
assert.ok(a.x.length >= a.cap - 8, `grows to the cap (${a.x.length}/${a.cap})`)
|
|
14
|
+
assert.deepEqual(a.x, b.x, 'same seed, same line')
|
|
15
|
+
assert.ok(a.x.every((v, i) => v >= 2 && v <= 398 && a.y[i] >= 2 && a.y[i] <= 298), 'held inside the frame')
|
|
16
|
+
const before = a.x.slice()
|
|
17
|
+
a.poke(a.x[0], a.y[0] + 1, true)
|
|
18
|
+
assert.notDeepEqual(a.x, before, 'poke moves the line')
|
|
19
|
+
assert.match(a.pathD(400, 300), /^M.*Z$/, 'one closed path')
|
|
20
|
+
console.log('kol-growth selftest ok')
|
package/src/shapes.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Shapes — the density sources the growing line reads with `densityFrom`: dark on light, at the frame's aspect.
|
|
2
|
+
// The one DOM in this engine (as `densityFrom` itself): a shape is a canvas, so these run in a browser.
|
|
3
|
+
|
|
4
|
+
/* the built-in orb, kol-fxr's `sourceCanvas('orb')` carried verbatim: a radial gradient, black at the centre */
|
|
5
|
+
export function orbCanvas(aspect) {
|
|
6
|
+
const W = 400, H = Math.max(1, Math.round(W / aspect))
|
|
7
|
+
const c = document.createElement('canvas')
|
|
8
|
+
c.width = W; c.height = H
|
|
9
|
+
const g = c.getContext('2d')
|
|
10
|
+
g.fillStyle = '#fff'; g.fillRect(0, 0, W, H)
|
|
11
|
+
const r = Math.min(W, H) * 0.46
|
|
12
|
+
const grd = g.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, r)
|
|
13
|
+
grd.addColorStop(0, '#000'); grd.addColorStop(1, '#fff')
|
|
14
|
+
g.fillStyle = grd; g.fillRect(0, 0, W, H)
|
|
15
|
+
return c
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* any SVG path `d`, filled solid black on white, centred and scaled to `fit` of the frame — kol-fxr's `vectorCanvas`
|
|
19
|
+
* for a bare path: synchronous (no image decode), the bounds measured by the browser's own SVG `getBBox` */
|
|
20
|
+
export function pathCanvas(d, aspect, fit = 0.6) {
|
|
21
|
+
const W = 400, H = Math.max(1, Math.round(W / aspect))
|
|
22
|
+
const c = document.createElement('canvas')
|
|
23
|
+
c.width = W; c.height = H
|
|
24
|
+
const g = c.getContext('2d')
|
|
25
|
+
g.fillStyle = '#fff'; g.fillRect(0, 0, W, H)
|
|
26
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
27
|
+
svg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden')
|
|
28
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
29
|
+
path.setAttribute('d', d)
|
|
30
|
+
svg.appendChild(path)
|
|
31
|
+
document.body.appendChild(svg)
|
|
32
|
+
let b
|
|
33
|
+
try { b = path.getBBox() } finally { svg.remove() }
|
|
34
|
+
if (!b.width || !b.height) return c
|
|
35
|
+
const k = Math.min(W / b.width, H / b.height) * fit
|
|
36
|
+
g.setTransform(k, 0, 0, k, (W - b.width * k) / 2 - b.x * k, (H - b.height * k) / 2 - b.y * k)
|
|
37
|
+
g.fillStyle = '#000'
|
|
38
|
+
g.fill(new Path2D(d))
|
|
39
|
+
return c
|
|
40
|
+
}
|