@kolkrabbi/kol-growth 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,14 @@
1
+ # @kolkrabbi/kol-growth
2
+
3
+ ## 0.2.0 — 2026-10-30
4
+
5
+ - **`bleed` and `step(zone)`** (SignedOutStageStill, kol-fxr — a finished line jittered at the edges). `bleed` (default 0, today's framed clamp) lets nodes run that many units past every edge; a node outside the frame is frozen — no force, split or merge — and the spatial hash spans the bled frame. `step(zone)` with `{ x, y, r }` freezes every node outside the zone the same way. kol-fxr's fixed `engine.js`, carried verbatim; framed consumers are unchanged.
6
+
7
+ ## 0.1.0 — 2026-10-30
8
+
9
+ - **First release — the engine tier's fourth package** (ARCHITECTURE §3, GrowthLineStage from kol-atlas). kol-fxr's
10
+ growing line moved in so fxr and every other app grow the same line from one engine.
11
+ - `Growth` · `densityFrom` · `SIM_W` — kol-fxr `src/loops/growth/engine.js`, carried verbatim.
12
+ - `mulberry32` — kol-fxr `src/loops/gl/rng.js`, verbatim.
13
+ - `orbCanvas` (fxr's `sourceCanvas('orb')`, verbatim) and `pathCanvas` (new: any SVG path `d` as a density source).
14
+ - `src/selftest.mjs` — grows to the cap, a seed repeats, `poke` moves the line.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @kolkrabbi/kol-growth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.step({ x, y, r }) // a finished line: only nodes inside the zone move, the rest stay frozen
15
+ sim.poke(px, py, down) // the pointer as a force: a push, or a blast when down
16
+ sim.draw(ctx, w, h, { ink, width: 1 }) // the one path onto a canvas
17
+ sim.pathD(w, h) // the same path as an SVG `d`
18
+ ```
19
+
20
+ | export | does |
21
+ |---|---|
22
+ | `Growth` | the sim — `step`, `seed`, `poke`, `draw`, `pathD`, `setParams({ minGap, maxGap, contrast, growth, cap, bleed })` — `bleed` (default 0, the framed clamp) lets the line run that far past every edge, where it freezes |
23
+ | `densityFrom(canvas, cols?, invert?)` | 1 − luma on a grid, lightly blurred — the field the spacing reads |
24
+ | `SIM_W` | 1000, the sim width kol-fxr's loops use |
25
+ | `mulberry32(seed)` | the seeded 0..1 PRNG; pass as `rand` and the same seed grows the same line |
26
+ | `orbCanvas(aspect)` | the built-in orb, dark at the centre |
27
+ | `pathCanvas(d, aspect, fit?)` | any SVG path `d`, solid black on white, centred at `fit` of the frame |
28
+
29
+ `densityFrom`, `orbCanvas` and `pathCanvas` draw on a canvas, so they need a browser; the sim itself runs anywhere
30
+ (`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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.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,221 @@
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
+ // THE EDGE (plan 44 6.1, user 2026-10-30: *"allow growth outside of viewport and limit it in some other way so it just
13
+ // sits still, not this jittering"*). By default the line is held 2 units inside the frame — the effect and the generator
14
+ // draw on framed paper. `bleed` lets it run that far past every edge instead, and a node outside the frame FREEZES: no
15
+ // force, no split, no merge, no growth. The wall the line piled into is gone; what leaves the frame sits still.
16
+ // A finished line never settles on its own (merges and splits trade nodes forever), so `step(zone)` freezes everything
17
+ // outside `zone` `{ x, y, r }` the same way — the pointer moves the line around it, the rest stays put.
18
+
19
+ export const SIM_W = 1000
20
+
21
+ /* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
22
+ export function densityFrom(canvas, cols = 160, invert = false) {
23
+ const rows = Math.max(1, Math.round(cols * canvas.height / canvas.width))
24
+ const c = document.createElement('canvas')
25
+ c.width = cols; c.height = rows
26
+ const g = c.getContext('2d', { willReadFrequently: true })
27
+ g.filter = 'blur(1px)'
28
+ g.drawImage(canvas, 0, 0, cols, rows)
29
+ const d = g.getImageData(0, 0, cols, rows).data
30
+ const out = new Float32Array(cols * rows)
31
+ for (let i = 0; i < out.length; i++) {
32
+ const j = i * 4
33
+ const a = d[j + 3] / 255
34
+ /* transparent reads as paper */
35
+ const lum = ((0.2126 * d[j] + 0.7152 * d[j + 1] + 0.0722 * d[j + 2]) / 255) * a + (1 - a)
36
+ out[i] = invert ? lum : 1 - lum
37
+ }
38
+ return { cols, rows, data: out }
39
+ }
40
+
41
+ export class Growth {
42
+ constructor({ w = SIM_W, h = SIM_W, density = null, rand = Math.random, cap = 6000 }) {
43
+ this.w = w; this.h = h
44
+ this.density = density
45
+ this.rand = rand
46
+ this.cap = cap
47
+ this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4; this.bleed = 0
48
+ this.x = []; this.y = []
49
+ }
50
+
51
+ setParams({ minGap, maxGap, contrast, growth, cap, bleed }) {
52
+ if (minGap != null) this.minGap = minGap
53
+ if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
54
+ if (contrast != null) this.contrast = contrast
55
+ if (growth != null) this.growth = growth
56
+ if (cap != null) this.cap = cap
57
+ if (bleed != null) this.bleed = bleed
58
+ }
59
+
60
+ /* off the frame, with a bleed: frozen */
61
+ out(x, y, zone) {
62
+ if (zone && (x - zone.x) ** 2 + (y - zone.y) ** 2 > zone.r * zone.r) return true
63
+ return this.bleed > 0 && (x < 0 || y < 0 || x > this.w || y > this.h)
64
+ }
65
+
66
+ /* a small ring of nodes — at (cx, cy) in 0..1, or at the field's centre of darkness */
67
+ seed(cx, cy, r = 14) {
68
+ if (cx == null && this.density) {
69
+ const { cols, rows, data } = this.density
70
+ let sx = 0, sy = 0, sw = 0
71
+ 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 }
72
+ if (sw > 0) { cx = (sx / sw + 0.5) / cols; cy = (sy / sw + 0.5) / rows }
73
+ }
74
+ cx = (cx ?? 0.5) * this.w; cy = (cy ?? 0.5) * this.h
75
+ const n = 24
76
+ this.x = []; this.y = []
77
+ for (let i = 0; i < n; i++) {
78
+ const a = (i / n) * Math.PI * 2
79
+ this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
80
+ }
81
+ }
82
+
83
+ dens(x, y) {
84
+ const D = this.density
85
+ if (!D) return 0.5
86
+ const c = Math.min(D.cols - 1, Math.max(0, Math.floor((x / this.w) * D.cols)))
87
+ const r = Math.min(D.rows - 1, Math.max(0, Math.floor((y / this.h) * D.rows)))
88
+ return D.data[r * D.cols + c]
89
+ }
90
+
91
+ gap(x, y) {
92
+ const d = Math.pow(Math.min(1, Math.max(0, this.dens(x, y))), 1 / this.contrast)
93
+ return this.maxGap + (this.minGap - this.maxGap) * d
94
+ }
95
+
96
+ step(zone = null) {
97
+ const { x, y, w, h } = this
98
+ let n = x.length
99
+ if (n < 3) return
100
+ const R = this.maxGap, b = this.bleed
101
+ /* spatial hash on the largest gap, over the frame and its bleed */
102
+ const cols = Math.ceil((w + 2 * b) / R) + 1, rows = Math.ceil((h + 2 * b) / R) + 1
103
+ const head = new Int32Array(cols * rows).fill(-1)
104
+ const next = new Int32Array(n)
105
+ const cell = (px, py) => Math.min(rows - 1, Math.max(0, Math.floor((py + b) / R))) * cols + Math.min(cols - 1, Math.max(0, Math.floor((px + b) / R)))
106
+ const frozen = new Uint8Array(n)
107
+ for (let i = 0; i < n; i++) frozen[i] = this.out(x[i], y[i], zone) ? 1 : 0
108
+ for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
109
+ const gaps = new Float32Array(n)
110
+ for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
111
+ const fx = new Float32Array(n), fy = new Float32Array(n)
112
+ for (let i = 0; i < n; i++) {
113
+ if (frozen[i]) continue
114
+ const ip = (i - 1 + n) % n, inx = (i + 1) % n
115
+ /* smooth: toward the neighbours' midpoint */
116
+ fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
117
+ fy[i] += ((y[ip] + y[inx]) / 2 - y[i]) * 0.35
118
+ /* repel: every non-adjacent node inside the local gap */
119
+ const gi = gaps[i]
120
+ const cx = Math.floor((x[i] + b) / R), cy = Math.floor((y[i] + b) / R)
121
+ for (let oy = -1; oy <= 1; oy++) {
122
+ const ry = cy + oy
123
+ if (ry < 0 || ry >= rows) continue
124
+ for (let ox = -1; ox <= 1; ox++) {
125
+ const rx = cx + ox
126
+ if (rx < 0 || rx >= cols) continue
127
+ for (let j = head[ry * cols + rx]; j !== -1; j = next[j]) {
128
+ if (j === i || j === ip || j === inx) continue
129
+ const dx = x[i] - x[j], dy = y[i] - y[j]
130
+ const g = (gi + gaps[j]) / 2
131
+ const d2 = dx * dx + dy * dy
132
+ if (d2 >= g * g || d2 === 0) continue
133
+ const d = Math.sqrt(d2)
134
+ const push = ((g - d) / g) * 0.5
135
+ fx[i] += (dx / d) * push * g * 0.5
136
+ fy[i] += (dy / d) * push * g * 0.5
137
+ }
138
+ }
139
+ }
140
+ }
141
+ const m = b > 0 ? -b : 2
142
+ for (let i = 0; i < n; i++) {
143
+ if (frozen[i]) continue
144
+ const lim = gaps[i] * 0.25
145
+ x[i] = Math.min(w - m, Math.max(m, x[i] + Math.max(-lim, Math.min(lim, fx[i]))))
146
+ y[i] = Math.min(h - m, Math.max(m, y[i] + Math.max(-lim, Math.min(lim, fy[i]))))
147
+ }
148
+ /* resample: split long edges, merge short ones, then grow */
149
+ const nx = [], ny = []
150
+ let budget = this.cap - n
151
+ for (let i = 0; i < n; i++) {
152
+ const j = (i + 1) % n
153
+ const g = this.gap(x[i], y[i])
154
+ const dx = x[j] - x[i], dy = y[j] - y[i]
155
+ const len = Math.hypot(dx, dy)
156
+ /* an edge with a frozen end keeps its nodes as they are */
157
+ if (frozen[i] || frozen[j]) { nx.push(x[i]); ny.push(y[i]); continue }
158
+ if (len < g * 0.2 && n > 8) continue
159
+ nx.push(x[i]); ny.push(y[i])
160
+ if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); budget-- }
161
+ }
162
+ for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
163
+ const i = Math.floor(this.rand() * nx.length), j = (i + 1) % nx.length
164
+ if (this.out(nx[i], ny[i], zone) || this.out(nx[j], ny[j], zone)) continue
165
+ /* a split nudged sideways starts a new fold */
166
+ const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
167
+ const s = (this.rand() - 0.5) * 0.6
168
+ nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
169
+ ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
170
+ }
171
+ this.x = nx; this.y = ny
172
+ }
173
+
174
+ /* THE POINTER AS A FORCE (sim units) — penrose/common.js's verbs on this line: a move pushes the nodes out of a ring
175
+ * 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
176
+ * a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. */
177
+ poke(px, py, down = false, amount = 1) {
178
+ if (!amount) return
179
+ const { x, y, w, h } = this, s = Math.min(w, h), m = this.bleed > 0 ? -this.bleed : 2
180
+ const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
181
+ const strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
182
+ for (let i = 0; i < x.length; i++) {
183
+ const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
184
+ if (d2 >= r2 || d2 < 1e-6 || this.out(x[i], y[i])) continue
185
+ const d = Math.sqrt(d2), f = (1 - d / reach) * strength
186
+ x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
187
+ y[i] = Math.min(h - m, Math.max(m, y[i] + (dy / d) * f))
188
+ }
189
+ }
190
+
191
+ /* the one path, scaled onto a w × h frame */
192
+ draw(ctx, fw, fh, { ink = '#111', paper = null, width = 1.2 } = {}) {
193
+ if (paper) { ctx.fillStyle = paper; ctx.fillRect(0, 0, fw, fh) }
194
+ const { x, y } = this
195
+ const n = x.length
196
+ if (n < 3) return
197
+ const sx = fw / this.w, sy = fh / this.h
198
+ ctx.beginPath()
199
+ /* through the edge midpoints with the nodes as control points — a smooth closed curve */
200
+ const mx = (i) => ((x[i] + x[(i + 1) % n]) / 2) * sx, my = (i) => ((y[i] + y[(i + 1) % n]) / 2) * sy
201
+ ctx.moveTo(mx(n - 1), my(n - 1))
202
+ for (let i = 0; i < n; i++) ctx.quadraticCurveTo(x[i] * sx, y[i] * sy, mx(i), my(i))
203
+ ctx.closePath()
204
+ ctx.strokeStyle = ink
205
+ ctx.lineWidth = width
206
+ ctx.lineJoin = 'round'
207
+ ctx.stroke()
208
+ }
209
+
210
+ /* the same path as one SVG `d`, in frame units */
211
+ pathD(fw, fh) {
212
+ const { x, y } = this
213
+ const n = x.length
214
+ if (n < 3) return ''
215
+ const sx = fw / this.w, sy = fh / this.h, f = (v) => v.toFixed(2)
216
+ const mx = (i) => f(((x[i] + x[(i + 1) % n]) / 2) * sx), my = (i) => f(((y[i] + y[(i + 1) % n]) / 2) * sy)
217
+ let d = `M${mx(n - 1)} ${my(n - 1)}`
218
+ for (let i = 0; i < n; i++) d += `Q${f(x[i] * sx)} ${f(y[i] * sy)} ${mx(i)} ${my(i)}`
219
+ return d + 'Z'
220
+ }
221
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Growth, densityFrom, SIM_W } from './engine.js'
2
+ export { mulberry32 } from './rng.js'
3
+ export { orbCanvas, pathCanvas } from './shapes.js'
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
+ }
@@ -0,0 +1,31 @@
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
+ /* a zone step freezes everything outside it */
21
+ const zone = { x: 200, y: 150, r: 40 }, out = a.x.map((v, i) => (v - 200) ** 2 + (a.y[i] - 150) ** 2 > 1600)
22
+ const px = a.x.slice(), py = a.y.slice(), pn = a.x.length
23
+ a.step(zone)
24
+ if (a.x.length === pn) assert.ok(out.every((o, i) => !o || (a.x[i] === px[i] && a.y[i] === py[i])), 'outside the zone stays put')
25
+ /* with a bleed the line may leave the frame, and stays inside the bled frame */
26
+ const c = new Growth({ w: 200, h: 150, rand: mulberry32(3), cap: 900 })
27
+ c.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8, bleed: 30 })
28
+ c.seed()
29
+ for (let i = 0; i < 600 && c.x.length < c.cap; i++) c.step()
30
+ assert.ok(c.x.every((v, i) => v >= -30 && v <= 230 && c.y[i] >= -30 && c.y[i] <= 180), 'held inside the bled frame')
31
+ 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
+ }