@kolkrabbi/kol-component 0.321.0 → 0.322.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/package.json +2 -2
- package/src/organisms/GrowthLineStage.jsx +148 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.322.0",
|
|
4
4
|
"description": "The core component library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"embla-carousel-react": "^8.6.0",
|
|
27
27
|
"pdfjs-dist": "^6.3.289",
|
|
28
28
|
"react-syntax-highlighter": "^16.1.1",
|
|
29
|
+
"@kolkrabbi/kol-growth": "^0.4.0",
|
|
29
30
|
"@kolkrabbi/kol-search": "^0.3.0",
|
|
30
|
-
"@kolkrabbi/kol-growth": "^0.3.0",
|
|
31
31
|
"@kolkrabbi/kol-markdown": "^0.2.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
@@ -4,104 +4,184 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
|
4
4
|
|
|
5
5
|
/* THE GROWING LINE AS A STAGE (GrowthLineStage, kol-atlas 2026-10-30) — kol-fxr's HomeStage sim loop on
|
|
6
6
|
* @kolkrabbi/kol-growth: one closed line grows into `shape`, packing tighter where it is dark. One <canvas> filling its
|
|
7
|
-
* box at dpr ≤ 2; the sim runs in CSS px, so the spacing reads the same on a phone as on a desk.
|
|
8
|
-
*
|
|
9
|
-
* `data-theme` flip repaints a finished line; the stage clears to transparent, no paper.
|
|
7
|
+
* box at dpr ≤ 2; the sim runs in CSS px, so the spacing reads the same on a phone as on a desk. Ink is the canvas's
|
|
8
|
+
* computed `color` (a `text-*` class), a `data-theme` flip repaints a sleeping line; the stage clears to transparent.
|
|
10
9
|
*
|
|
11
|
-
* THE
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the window,
|
|
16
|
-
*
|
|
10
|
+
* THE LINE SETTLES (engine `settle`, GrowthLineIntro 2026-10-31, kol-fxr plan 45): every node carries a heat its motion,
|
|
11
|
+
* a split, the pointer or a pressing neighbour raise and that fades by itself; the loop steps while the line grows or
|
|
12
|
+
* anything is warm — two steps a frame while it grows, one while it settles — and goes still without a hard stop. It runs
|
|
13
|
+
* 48px past the edges (engine `bleed`), frozen there. THE POINTER IS A FORCE, soft: a move pushes the line aside as hard
|
|
14
|
+
* as it moves (a resting pointer is no force), a press blasts a crater it regrows into. Read on the window, so the stage
|
|
15
|
+
* can sit behind a page; a press on a control is the control's.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
17
|
+
* A RESIZE KEEPS THE LINE — the nodes re-centre by half the size change, the shape is re-sampled at the new aspect, and
|
|
18
|
+
* a bigger box wakes the line to grow on into it; it never reseeds. A SLOW WAVE (`wave`, default 24 s · 9 px · 640 px
|
|
19
|
+
* crests) drifts the drawn line every frame, the sim untouched; `wave={false}` lets a settled line sleep (no rAF).
|
|
20
|
+
*
|
|
21
|
+
* THE INTRO (`intro`): one line from load to page. It grows on the shape's density (at `fit` 0.5) for 3.8 s, drawn only
|
|
22
|
+
* through the shape in the emphasis ink; rests 4 s breathing (ink 0.7–1.0 on a 2 s period, growth off after the first
|
|
23
|
+
* breath); then lets go over 4 s — the density blends shape → orb, `params` take over, every node warms, the stage's ink
|
|
24
|
+
* fades up as the shape's fades out, and the pointer waits until it ends. `onIntroEnd` fires then.
|
|
25
|
+
*
|
|
26
|
+
* REDUCED MOTION — no intro (`onIntroEnd` fires at once), no wave, no pointer force: the finished line is grown off
|
|
27
|
+
* screen and drawn once.
|
|
19
28
|
*
|
|
20
29
|
* `shape` — 'orb' · an SVG path `d` (centred at `fit` of the box) · `(w, h) => canvas` (dark on light). */
|
|
21
|
-
const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth:
|
|
30
|
+
const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth: 2.5 }
|
|
31
|
+
const WAVE = { ms: 24000, a: 9, l: 640 }
|
|
32
|
+
/* the intro, ms from its first frame: the line fills the shape · it breathes · the shape lets go */
|
|
33
|
+
const INTRO_PARAMS = { minGap: 5, maxGap: 18, contrast: 1.2, growth: 5 }
|
|
34
|
+
const MARK_MS = 3800, REST_MS = 4000, BREATH_MS = 2000, RELEASE_MS = 4000
|
|
22
35
|
|
|
23
|
-
const shapeCanvas = (shape, w, h, fit) => {
|
|
36
|
+
const shapeCanvas = (shape, w, h, fit, W) => {
|
|
24
37
|
if (typeof shape === 'function') return shape(w, h)
|
|
25
|
-
if (shape === 'orb' || !shape) return orbCanvas(w / h)
|
|
26
|
-
return pathCanvas(shape, w / h, fit)
|
|
38
|
+
if (shape === 'orb' || !shape) return orbCanvas(w / h, W)
|
|
39
|
+
return pathCanvas(shape, w / h, fit, W)
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
export default function GrowthLineStage({ shape = 'orb', fit
|
|
42
|
+
export default function GrowthLineStage({ shape = 'orb', fit, params = ORB_PARAMS, cap, pointer = true, seed, wave = WAVE, intro = false, onIntroEnd, className = '' }) {
|
|
30
43
|
const ref = useRef(null)
|
|
31
44
|
const reduced = usePrefersReducedMotion()
|
|
32
45
|
/* the props are read at (re)start; the effect re-runs when one changes by value */
|
|
33
|
-
const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, reduced])
|
|
46
|
+
const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, wave, intro, reduced])
|
|
34
47
|
const shapeRef = useRef(shape)
|
|
35
48
|
shapeRef.current = shape
|
|
49
|
+
const endRef = useRef(onIntroEnd)
|
|
50
|
+
endRef.current = onIntroEnd
|
|
36
51
|
|
|
37
52
|
useEffect(() => {
|
|
38
53
|
const canvas = ref.current
|
|
39
54
|
if (!canvas) return undefined
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
const k = fit ?? (intro ? 0.5 : 0.6)
|
|
56
|
+
const drift = wave && !reduced ? wave : null
|
|
57
|
+
const capOf = () => cap ?? Math.round(w * h / 90)
|
|
58
|
+
let sim = null, raf = 0, w = 0, h = 0, dpr = 1, it = null, orbed = false
|
|
59
|
+
let ptr = null, sm = null
|
|
42
60
|
const force = pointer && !reduced
|
|
43
|
-
const
|
|
61
|
+
const fitCanvas = () => {
|
|
44
62
|
const r = canvas.getBoundingClientRect()
|
|
45
|
-
w = Math.max(1, r.width); h = Math.max(1, r.height)
|
|
46
|
-
const src = shapeCanvas(shapeRef.current, w, h, fit)
|
|
47
|
-
if (!src) return false
|
|
48
|
-
const dpr = Math.min(2, window.devicePixelRatio || 1)
|
|
63
|
+
w = Math.max(1, r.width); h = Math.max(1, r.height); dpr = Math.min(2, window.devicePixelRatio || 1)
|
|
49
64
|
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr)
|
|
50
65
|
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
}
|
|
67
|
+
/* the shape as an alpha mask at the canvas's own resolution (dark → opaque), and the layer the line is drawn on
|
|
68
|
+
* before the mask cuts it */
|
|
69
|
+
const masks = () => {
|
|
70
|
+
const src = shapeCanvas(shapeRef.current, w, h, k, canvas.width)
|
|
71
|
+
const m = document.createElement('canvas'); m.width = canvas.width; m.height = canvas.height
|
|
72
|
+
const g = m.getContext('2d', { willReadFrequently: true })
|
|
73
|
+
if (src) g.drawImage(src, 0, 0, m.width, m.height)
|
|
74
|
+
const img = g.getImageData(0, 0, m.width, m.height), d = img.data
|
|
75
|
+
for (let i = 0; i < d.length; i += 4) { d[i + 3] = 255 - (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]); d[i] = d[i + 1] = d[i + 2] = 0 }
|
|
76
|
+
g.putImageData(img, 0, 0)
|
|
77
|
+
it.mask = m
|
|
78
|
+
it.layer = document.createElement('canvas'); it.layer.width = canvas.width; it.layer.height = canvas.height
|
|
79
|
+
}
|
|
80
|
+
const end = () => { it = null; endRef.current?.() }
|
|
81
|
+
const start = () => {
|
|
82
|
+
fitCanvas()
|
|
83
|
+
const src = shapeCanvas(shapeRef.current, w, h, k)
|
|
84
|
+
if (!src) return false
|
|
85
|
+
sim = new Growth({ w, h, density: densityFrom(src), rand: seed == null ? Math.random : mulberry32(seed), cap: capOf() })
|
|
86
|
+
sim.setParams({ bleed: 48, settle: 60, ...(intro && !reduced ? INTRO_PARAMS : params) })
|
|
87
|
+
if (intro && !reduced) {
|
|
88
|
+
sim.seed(0.5, 0.5)
|
|
89
|
+
const probe = document.createElement('span'); probe.className = 'text-emphasis'; canvas.parentElement.append(probe)
|
|
90
|
+
it = { t0: 0, ink: 1, out: 0, bright: getComputedStyle(probe).color, mark: sim.density, mix: null }
|
|
91
|
+
probe.remove(); masks()
|
|
92
|
+
} else {
|
|
93
|
+
sim.seed()
|
|
94
|
+
if (intro) endRef.current?.()
|
|
95
|
+
}
|
|
61
96
|
return true
|
|
62
97
|
}
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
/* the shape lets go: the page's spacing, every node warm, the density blending from the shape to the orb */
|
|
99
|
+
const release = () => {
|
|
100
|
+
orbed = true
|
|
101
|
+
const orb = densityFrom(orbCanvas(w / h))
|
|
102
|
+
it.orb = orb; it.mix = { cols: orb.cols, rows: orb.rows, data: new Float32Array(orb.data.length) }
|
|
103
|
+
sim.density = it.mix
|
|
104
|
+
sim.setParams(params)
|
|
105
|
+
sim.full = false; sim.heat.fill(1); sim.hot = sim.x.length
|
|
106
|
+
}
|
|
107
|
+
const blend = (u) => {
|
|
108
|
+
const a = it.mark.data, b = it.orb.data, m = it.mix.data
|
|
109
|
+
/* the mark was sampled at its own grid; read it by position if the orb's differs */
|
|
110
|
+
if (a.length === m.length) for (let i = 0; i < m.length; i++) m[i] = a[i] + (b[i] - a[i]) * u
|
|
111
|
+
else for (let i = 0; i < m.length; i++) m[i] = b[i] * u
|
|
112
|
+
}
|
|
113
|
+
const paint = (now) => {
|
|
114
|
+
const g = canvas.getContext('2d'), ink = getComputedStyle(canvas).color
|
|
115
|
+
const wv = drift && { a: drift.a, k: (2 * Math.PI) / drift.l, p: (2 * Math.PI * now) / drift.ms }
|
|
65
116
|
g.clearRect(0, 0, w, h)
|
|
66
|
-
sim.draw(g, w, h, { ink
|
|
117
|
+
if (!it) { sim.draw(g, w, h, { ink, width: 1, wave: wv }); return }
|
|
118
|
+
const L = it.layer.getContext('2d')
|
|
119
|
+
L.globalCompositeOperation = 'source-over'; L.setTransform(1, 0, 0, 1, 0, 0); L.clearRect(0, 0, it.layer.width, it.layer.height)
|
|
120
|
+
L.setTransform(dpr, 0, 0, dpr, 0, 0); sim.draw(L, w, h, { ink: it.bright, width: 1, wave: wv })
|
|
121
|
+
/* the whole line in the stage's ink once the shape lets go, fading up; the shape's in the emphasis ink, at the breath */
|
|
122
|
+
if (it.out > 0) { g.globalAlpha = it.out; sim.draw(g, w, h, { ink, width: 1, wave: wv }); g.globalAlpha = 1 }
|
|
123
|
+
L.setTransform(1, 0, 0, 1, 0, 0); L.globalCompositeOperation = 'destination-in'; L.drawImage(it.mask, 0, 0)
|
|
124
|
+
g.save(); g.setTransform(1, 0, 0, 1, 0, 0)
|
|
125
|
+
g.globalAlpha = it.ink * (1 - it.out); g.drawImage(it.layer, 0, 0)
|
|
126
|
+
g.restore()
|
|
67
127
|
}
|
|
68
|
-
/*
|
|
69
|
-
const frame = () => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
128
|
+
/* the line steps while it grows or anything is warm; with a wave it is drawn every frame, without one it sleeps */
|
|
129
|
+
const frame = (now) => {
|
|
130
|
+
let steps = !sim.full ? 2 : sim.hot > 0 ? 1 : 0
|
|
131
|
+
if (it) {
|
|
132
|
+
if (!it.t0) it.t0 = now
|
|
133
|
+
const t = now - it.t0
|
|
134
|
+
if (t < MARK_MS) steps = 4
|
|
135
|
+
else if (t < MARK_MS + REST_MS) {
|
|
136
|
+
/* it keeps filling the shape's far corners through the first breath, then holds */
|
|
137
|
+
if (!it.rest && t > MARK_MS + BREATH_MS) { it.rest = true; sim.setParams({ growth: 0 }) }
|
|
138
|
+
it.ink = 0.7 + 0.3 * Math.cos((2 * Math.PI * (t - MARK_MS)) / BREATH_MS)
|
|
139
|
+
} else {
|
|
140
|
+
if (!it.free) { it.free = true; it.ink = 1; release() }
|
|
141
|
+
const u = Math.min(1, (t - MARK_MS - REST_MS) / RELEASE_MS)
|
|
142
|
+
blend(u); it.out = u
|
|
143
|
+
if (u >= 1) { sim.density = it.orb; end() }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (ptr && !it) {
|
|
147
|
+
/* low-passed — the raw one twitches; 0.35 follows without lag */
|
|
148
|
+
const to = sm ? { x: sm.x + (ptr.x - sm.x) * 0.35, y: sm.y + (ptr.y - sm.y) * 0.35 } : { x: ptr.x, y: ptr.y }
|
|
149
|
+
/* the push is the pointer's own speed — a resting pointer is no force, a slow drift a light brush, a sweep a shove */
|
|
150
|
+
const speed = sm ? Math.hypot(to.x - sm.x, to.y - sm.y) : 0
|
|
151
|
+
if (ptr.down || speed > 0.3) { sim.poke(to.x, to.y, ptr.down, ptr.down ? 1 : Math.min(1, speed / 6)); steps = Math.max(steps, 1) }
|
|
79
152
|
sm = to
|
|
80
153
|
} else sm = null
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
paint()
|
|
85
|
-
raf = growing || idle > 0 ? requestAnimationFrame(frame) : 0
|
|
86
|
-
}
|
|
87
|
-
const wake = () => { if (!raf && sim) raf = requestAnimationFrame(frame) }
|
|
88
|
-
const restart = () => {
|
|
89
|
-
cancelAnimationFrame(raf); raf = 0
|
|
90
|
-
if (!start()) return
|
|
91
|
-
/* reduced motion grows 4× the splits a step: nothing is watched growing, so it reaches the cap sooner */
|
|
92
|
-
if (reduced) sim.setParams({ growth: sim.growth * 4 })
|
|
93
|
-
raf = requestAnimationFrame(reduced ? settle : frame)
|
|
154
|
+
for (let i = 0; i < steps; i++) sim.step()
|
|
155
|
+
paint(now)
|
|
156
|
+
raf = drift || it || ptr || !sim.full || sim.hot > 0 ? requestAnimationFrame(frame) : 0
|
|
94
157
|
}
|
|
158
|
+
const wake = () => { if (!raf && sim && !reduced) raf = requestAnimationFrame(frame) }
|
|
95
159
|
/* reduced motion: grown to the end off screen, ~12ms of steps a frame so the page never blocks, then drawn once */
|
|
96
|
-
const
|
|
160
|
+
const grow = () => {
|
|
97
161
|
const t = performance.now()
|
|
98
|
-
while (sim.
|
|
99
|
-
if (sim.
|
|
162
|
+
while ((!sim.full || sim.hot > 0) && performance.now() - t < 12) sim.step()
|
|
163
|
+
if (!sim.full || sim.hot > 0) { raf = requestAnimationFrame(grow); return }
|
|
100
164
|
raf = 0
|
|
101
|
-
paint()
|
|
165
|
+
paint(0)
|
|
166
|
+
}
|
|
167
|
+
/* A RESIZE KEEPS THE LINE: re-centred by half the size change, the frame and the shape re-sampled, never reseeded */
|
|
168
|
+
const resize = () => {
|
|
169
|
+
const r = canvas.getBoundingClientRect()
|
|
170
|
+
if (!sim || (Math.round(r.width) === Math.round(w) && Math.round(r.height) === Math.round(h))) return
|
|
171
|
+
const ow = w, oh = h
|
|
172
|
+
fitCanvas()
|
|
173
|
+
const dx = (w - ow) / 2, dy = (h - oh) / 2
|
|
174
|
+
for (let i = 0; i < sim.x.length; i++) { sim.x[i] += dx; sim.y[i] += dy }
|
|
175
|
+
sim.w = w; sim.h = h
|
|
176
|
+
sim.setParams({ cap: capOf() })
|
|
177
|
+
if (it) masks()
|
|
178
|
+
/* released from the intro, the line's shape is the orb */
|
|
179
|
+
else { const src = orbed ? orbCanvas(w / h) : shapeCanvas(shapeRef.current, w, h, k); if (src) sim.density = densityFrom(src) }
|
|
180
|
+
if (w > ow || h > oh) { sim.full = false; sim.heat.fill(0.5); sim.hot = sim.x.length }
|
|
181
|
+
if (reduced) { cancelAnimationFrame(raf); raf = requestAnimationFrame(grow) } else wake()
|
|
102
182
|
}
|
|
103
|
-
|
|
104
|
-
const ro = new ResizeObserver(
|
|
183
|
+
if (start()) raf = requestAnimationFrame(reduced ? grow : frame)
|
|
184
|
+
const ro = new ResizeObserver(resize)
|
|
105
185
|
ro.observe(canvas)
|
|
106
186
|
const at = (e) => {
|
|
107
187
|
const r = canvas.getBoundingClientRect()
|
|
@@ -118,8 +198,8 @@ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB
|
|
|
118
198
|
window.addEventListener('pointerup', onUp)
|
|
119
199
|
document.addEventListener('pointerleave', onLeave)
|
|
120
200
|
}
|
|
121
|
-
/* the ink follows a theme flip on a
|
|
122
|
-
const mo = new MutationObserver(() => { if (!raf && sim) paint() })
|
|
201
|
+
/* the ink follows a theme flip on a sleeping line too */
|
|
202
|
+
const mo = new MutationObserver(() => { if (!raf && sim) paint(performance.now()) })
|
|
123
203
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
124
204
|
return () => {
|
|
125
205
|
cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect()
|