02Leaf Veins Reaching Toward Scattered Attractor Points
Each of ATTRACTOR_COUNT attractors scattered inside a leaf picks its nearest node within INFLUENCE_RADIUS, each node sprouts a new one along the average of those pulls, attractors reached within KILL_DISTANCE are removed so branches split on their own, and thickness comes from each node's descendant count.
Best forIllustration for an eco brand's about section
ATTRACTOR_COUNTINFLUENCE_RADIUSKILL_DISTANCE

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="Veins reach toward attractor points scattered inside a leaf, and branches split as reached attractors are removed. Press Enter for a new seed, R to regrow the same seed from the start, and Space to pause."></canvas>
<p class="pc__status" data-role="status" aria-live="off">Veins reaching for attractors</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">Redraw</button>
<button class="pc__btn" data-role="replay" type="button">Same seed</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 Lines·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 Seed — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, Pause 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
function makeVein(rng, o) {
const ax = [];
const ay = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) {
ax.push(x);
ay.push(y);
}
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) {
bucketAdd(agrid, ax[i], ay[i], i);
}
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// Nodes 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v, x, y, parent) {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 Perimeter kill 안의 끌개를 지운다
function veinKill(v, from) {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a) {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) {
v.alive[a] = 0;
v.left -= 1;
}
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v) {
if (v.done) {
return true;
}
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 Nodes 하나 — 그 Nodes 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) {
continue;
}
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id) {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) {
bd = d2;
best = id;
}
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) {
v.reached = true;
}
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
mx += v.ax[a];
my += v.ay[a];
}
}
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
}
else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) {
continue;
}
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) {
continue;
}
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
v.done = true;
}
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v, c, col, alpha, base, k, cap) {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) {
continue;
}
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v, c, col, alpha) {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6);
}
}
c.fill();
}
// 잎 안에 뿌리는 끌개 점 수
const ATTRACTOR_COUNT = 900;
// 끌개가 마디를 끌어당기는 반경(px) — 이 안의 Nodes 중 가장 가까운 하나만 끈다
const INFLUENCE_RADIUS = 60;
// 마디가 이만큼 다가오면 그 끌개는 지운다(px)
const KILL_DISTANCE = 6;
// 새 Nodes 하나의 Length(px)
const SEGMENT_LENGTH = 2.6;
// 잎 모양 — 가운데·반 Length·가장 넓은 반폭(px)·기울기
const LEAF_CX = 292;
const LEAF_CY = 152;
const LEAF_HALF_LEN = 128;
const LEAF_HALF_W = 64;
const LEAF_TILT = -28 * DEG;
// 한 틱에 도는 성장 횟수와 Nodes 수·횟수 상한 — 프레임당 계산량 상한
const ITER_PER_FRAME = 1;
const MAX_NODES = 4000;
const MAX_ITER = 400;
const SEED0 = 47102;
const TXT_RUN = 'Veins reaching for attractors';
// 잎 좌표 — u 는 잎자루에서 잎끝 방향, v 는 그 옆 방향
function leafLocal(x, y) {
const dx = x - LEAF_CX;
const dy = y - LEAF_CY;
const c = Math.cos(-LEAF_TILT);
const s = Math.sin(-LEAF_TILT);
return [dx * c - dy * s, dx * s + dy * c];
}
function leafWorld(u, v) {
const c = Math.cos(LEAF_TILT);
const s = Math.sin(LEAF_TILT);
return [LEAF_CX + u * c - v * s, LEAF_CY + u * s + v * c];
}
// 반폭 — 잎자루 쪽(t = −1)은 둥글게, 잎끝(t = 1)은 뾰족하게 닫히도록 두 지수를 다르게 둔다
function leafHalf(t) { return LEAF_HALF_W * Math.pow(1 - t, 0.8) * Math.pow(1 + t, 0.62); }
function insideLeaf(x, y) {
const p = leafLocal(x, y);
const t = p[0] / LEAF_HALF_LEN;
if (t <= -1 || t >= 1) {
return false;
}
return Math.abs(p[1]) < leafHalf(t);
}
const ROOT = leafWorld(-LEAF_HALF_LEN - 14, 0);
function newVein(s) {
return makeVein(makeRng(s), {
count: ATTRACTOR_COUNT, influence: INFLUENCE_RADIUS, kill: KILL_DISTANCE, seg: SEGMENT_LENGTH,
rootX: ROOT[0], rootY: ROOT[1], maxNodes: MAX_NODES, maxIter: MAX_ITER,
x0: LEAF_CX - 150, y0: LEAF_CY - 150, x1: LEAF_CX + 150, y1: LEAF_CY + 150, inside: insideLeaf,
});
}
let vein = newVein(SEED0);
function leafPath(c) {
c.beginPath();
const n = 60;
for (let i = 0; i <= n; i += 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, leafHalf(t));
if (i === 0) {
c.moveTo(p[0], p[1]);
}
else {
c.lineTo(p[0], p[1]);
}
}
for (let i = n; i >= 0; i -= 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, -leafHalf(t));
c.lineTo(p[0], p[1]);
}
c.closePath();
}
function plant(s) {
clearArt(INK);
leafPath(actx);
actx.fillStyle = rgba(mix(PAL[2], INK, 0.58), 1);
actx.fill();
actx.strokeStyle = rgba(PAL[2], 0.55);
actx.lineWidth = 1.5;
actx.stroke();
vein = newVein(s);
}
function grow() {
for (let i = 0; i < ITER_PER_FRAME; i += 1) {
veinStep(vein);
}
return vein.done;
}
function paintFrame() {
blitArt();
veinDots(vein, ctx, PAL[5], 0.6);
veinDraw(vein, ctx, mix(PAL[5], PAPER, 0.5), 0.95, 0.5, 0.22, 5.2);
}
function statPairs() {
return [['Nodes', String(vein.nx.length)], ['Attractors left', String(vein.left)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = 'Fully grown — Enter for a new seed';
const TXT_PAUSE = 'Paused — Space to resume';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['Seed', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// VeinAttractorGrowth - vanilla 판(02-gen-art-vein-attractor-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./VeinAttractorGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function VeinAttractorGrowth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 끌개 점 잎맥 — 끌개마다 가장 가까운 마디를 찾고, 마디는 자기를 고른 끌개 방향들의 평균으로 한 칸 자란다. 마디가 닿은 끌개는 지운다
type VeinOpts = {
count: number; influence: number; kill: number; seg: number; rootX: number; rootY: number;
maxNodes: number; maxIter: number; x0: number; y0: number; x1: number; y1: number;
inside: (x: number, y: number) => boolean;
};
type Vein = {
o: VeinOpts; rng: () => number; ax: number[]; ay: number[]; alive: Uint8Array; left: number;
nx: number[]; ny: number[]; par: number[]; desc: number[]; grid: Buckets; agrid: Buckets;
iter: number; reached: boolean; stall: number; done: boolean;
};
function makeVein(rng: () => number, o: VeinOpts): Vein {
const ax: number[] = [];
const ay: number[] = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) { ax.push(x); ay.push(y); }
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) { bucketAdd(agrid, ax[i], ay[i], i); }
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v: Vein = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// 마디 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v: Vein, x: number, y: number, parent: number): void {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 둘레 kill 안의 끌개를 지운다
function veinKill(v: Vein, from: number): void {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a: number): boolean {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) { v.alive[a] = 0; v.left -= 1; }
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v: Vein): boolean {
if (v.done) { return true; }
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 마디 하나 — 그 마디 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) { continue; }
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id: number): boolean {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = id; }
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) { v.reached = true; }
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) { if (v.alive[a]) { mx += v.ax[a]; my += v.ay[a]; } }
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
} else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) { continue; }
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) { continue; }
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) { v.done = true; }
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number, base: number, k: number, cap: number): void {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) { continue; }
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number): void {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) { c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6); }
}
c.fill();
}
// 잎 안에 뿌리는 끌개 점 수
const ATTRACTOR_COUNT = 900;
// 끌개가 마디를 끌어당기는 반경(px) — 이 안의 마디 중 가장 가까운 하나만 끈다
const INFLUENCE_RADIUS = 60;
// 마디가 이만큼 다가오면 그 끌개는 지운다(px)
const KILL_DISTANCE = 6;
// 새 마디 하나의 길이(px)
const SEGMENT_LENGTH = 2.6;
// 잎 모양 — 가운데·반 길이·가장 넓은 반폭(px)·기울기
const LEAF_CX = 292;
const LEAF_CY = 152;
const LEAF_HALF_LEN = 128;
const LEAF_HALF_W = 64;
const LEAF_TILT = -28 * DEG;
// 한 틱에 도는 성장 횟수와 마디 수·횟수 상한 — 프레임당 계산량 상한
const ITER_PER_FRAME = 1;
const MAX_NODES = 4000;
const MAX_ITER = 400;
const SEED0 = 47102;
const TXT_RUN = '잎맥이 끌개로 뻗는 중';
// 잎 좌표 — u 는 잎자루에서 잎끝 방향, v 는 그 옆 방향
function leafLocal(x: number, y: number): [number, number] {
const dx = x - LEAF_CX;
const dy = y - LEAF_CY;
const c = Math.cos(-LEAF_TILT);
const s = Math.sin(-LEAF_TILT);
return [dx * c - dy * s, dx * s + dy * c];
}
function leafWorld(u: number, v: number): [number, number] {
const c = Math.cos(LEAF_TILT);
const s = Math.sin(LEAF_TILT);
return [LEAF_CX + u * c - v * s, LEAF_CY + u * s + v * c];
}
// 반폭 — 잎자루 쪽(t = −1)은 둥글게, 잎끝(t = 1)은 뾰족하게 닫히도록 두 지수를 다르게 둔다
function leafHalf(t: number): number { return LEAF_HALF_W * Math.pow(1 - t, 0.8) * Math.pow(1 + t, 0.62); }
function insideLeaf(x: number, y: number): boolean {
const p = leafLocal(x, y);
const t = p[0] / LEAF_HALF_LEN;
if (t <= -1 || t >= 1) { return false; }
return Math.abs(p[1]) < leafHalf(t);
}
const ROOT = leafWorld(-LEAF_HALF_LEN - 14, 0);
function newVein(s: number): Vein {
return makeVein(makeRng(s), {
count: ATTRACTOR_COUNT, influence: INFLUENCE_RADIUS, kill: KILL_DISTANCE, seg: SEGMENT_LENGTH,
rootX: ROOT[0], rootY: ROOT[1], maxNodes: MAX_NODES, maxIter: MAX_ITER,
x0: LEAF_CX - 150, y0: LEAF_CY - 150, x1: LEAF_CX + 150, y1: LEAF_CY + 150, inside: insideLeaf,
});
}
let vein: Vein = newVein(SEED0);
function leafPath(c: CanvasRenderingContext2D): void {
c.beginPath();
const n = 60;
for (let i = 0; i <= n; i += 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, leafHalf(t));
if (i === 0) { c.moveTo(p[0], p[1]); } else { c.lineTo(p[0], p[1]); }
}
for (let i = n; i >= 0; i -= 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, -leafHalf(t));
c.lineTo(p[0], p[1]);
}
c.closePath();
}
function plant(s: number): void {
clearArt(INK);
leafPath(actx);
actx.fillStyle = rgba(mix(PAL[2], INK, 0.58), 1);
actx.fill();
actx.strokeStyle = rgba(PAL[2], 0.55);
actx.lineWidth = 1.5;
actx.stroke();
vein = newVein(s);
}
function grow(): boolean {
for (let i = 0; i < ITER_PER_FRAME; i += 1) { veinStep(vein); }
return vein.done;
}
function paintFrame(): void {
blitArt();
veinDots(vein, ctx, PAL[5], 0.6);
veinDraw(vein, ctx, mix(PAL[5], PAPER, 0.5), 0.95, 0.5, 0.22, 5.2);
}
function statPairs(): [string, string][] {
return [['마디', String(vein.nx.length)], ['남은 끌개', String(vein.left)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="잎 모양 안에 흩어진 끌개 점을 향해 잎맥이 뻗고, 닿은 끌개가 지워지며 가지가 갈라지는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">잎맥이 끌개로 뻗는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}02 Leaf Veins Reaching Toward Scattered Attractor Points — Measured area 9.836% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Generative Art JavaScript: 9 Patterns That Grow
The post this demo lives in
Generative Art JavaScript: 9 Patterns That GrowWith generative art JavaScript, one seed gives the same picture on every redraw. 02 reaches veins toward scattered attra See all demos in this post
If you like this effect - similar demos
Creative UI04 Autumn Color That Creeps In from the Edges Away from the VeinsFalling Leaves Animation: 9 Scenes, Sway to Pile
Creative UI04 Attractor atlas — Clifford and de Jong, probabilistic flowers from millions of pointsLorenz Attractor: 9 Butterfly Chaos Demos
Creative UI03 Flow field line artCSS Background Pattern Generator: 9 Seeded Scenes