Generative Art JavaScript: 9 Patterns That Grow
With generative art JavaScript, one seed gives the same picture on every redraw. 02 reaches veins toward scattered attractors, 03 inserts nodes as a line
Generative art hands the brush to a set of rules written in advance, and the picture grows out of what those rules allow. Each of the nine scenes below is plain 2D canvas math with no library, no WebGL and no image file, so a finished picture comes from nothing more than a seed number and a few named constants.
Watching Nine Pictures Grow From a Single Seed on One Canvas
01 gen-art-flow-field-even-streamlines
02 gen-art-vein-attractor-growth
03 gen-art-folding-line-growth
04 gen-art-circle-pack-inflate
05 gen-art-crack-right-angle-spread
06 gen-art-tile-neighbor-match-collapse
07 gen-art-tree-ring-noise-growth
08 gen-art-lichen-edge-accretion
09 gen-art-hero-seeded-growth-scene
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Lines That Follow a Flow Field While Keeping Their Distance
- 02 Leaf Veins Reaching Toward Scattered Attractor Points
- 03 A Line That Folds as It Grows by Inserting Nodes
- 04 Circle Packing Where Circles Inflate Until They Touch
- 05 Cracks That Spread by Branching at Right Angles
- 06 Tiles That Fill In by Keeping Only Pieces That Match Their Neighbors
- 07 Tree Rings That Add One Wavy Layer at a Time
- 08 Lichen Colonies Spreading One Edge Cell at a Time
- 09 A Hero Backdrop Where Three Layers Grow From One Seed
Every tile waits 200ms on a blank board before it starts growing, then holds the finished picture for 900ms (1,400ms in 09) and moves on to the next seed. Once you click a canvas or a button, or press Enter, R or Space on a canvas, 01 to 08 stop cycling and stay on the current seed once it has fully grown, while 09 carries on to new seeds even after you touch it.
Generative Art JavaScript: 9 Patterns — Flow Lines, Leaf Veins, Folding Line, Circle Packing, Right-Angle Cracks, Matching Tiles, Tree Rings, Lichen Spread and a Growing Hero
The first three scenes are about how a line decides where to go next. In 01 the distance to other lines makes a line halt, in 02 attractor points set the direction of each branch, and in 03 the growing node count folds the line onto itself. Scenes 04 to 06 fill an area instead, with swelling circles, cracks that split at right angles and tiles that agree along their edges. 07 and 08 copy natural things that pile up over time, rings and lichen, and 09 stacks the math from 01, 02 and 04 into one first-screen scene driven by one seed.
All nine draw into a <canvas> with a fixed 480×300 coordinate space. Motion comes from a loop that adds up requestAnimationFrame timestamps and spends them on fixed 16ms ticks, running at most six ticks in one frame. The stylesheet has zero @keyframes rules, and its only transition is the background color of the buttons.
How much each growing scene changed during the capture
A headless browser shot 24 frames across 2 seconds, which leaves 23 gaps between neighboring shots, and the frames column counts how many of those gaps showed changed pixels.
| Scene | Cumulative area | Moving frames | Mean intensity |
|---|---|---|---|
| 01 Spaced flow lines | 28.0% | 17/23 | 97.0 |
| 02 Attractor leaf veins | 9.8% | 16/23 | 60.2 |
| 03 Node-inserting folded line | 15.5% | 23/23 | 81.9 |
| 04 Inflating circle packing | 44.2% | 23/23 | 92.8 |
| 05 Right-angle cracks | 42.4% | 17/23 | 56.6 |
| 06 Edge-matching tiles | 50.6% | 23/23 | 83.0 |
| 07 Tree rings | 35.8% | 18/23 | 138.6 |
| 08 Edge-cell lichen | 75.5% | 21/23 | 112.3 |
| 09 Three-layer hero | 39.0% | 23/23 | 73.4 |
Only 02 falls under 10%. Its nodes are thin 2.6px strokes that appear only inside the leaf outline, one growth pass per tick (ITER_PER_FRAME 1), which likely keeps the number of changed pixels low. 08 covers the most at 75.5%, most likely because it paints 4px cells at 60 per tick across all 9,000 cells of the board. 05 is done after 100 ticks, or 1,600ms, so its holding period likely lands inside the capture, which would explain why it moves in only 17 frames.
Which screens suit a picture that grows
For the first screen of a landing page, 09 can go in as is, and behind a text-heavy page the even grain of 01 is the quieter choice. Because a given seed yields the same picture each time, feeding in a post number or product number gives every page its own header art that stays put on reload, with no image file to host.
Waiting screens want a scene whose progress you can see. 08 spreads its colonies until the board is full, and 06 settles cell after cell like lifting fog, now and then collapsing once and filling again. Plant and eco brands fit the veins of 02 or the rings of 07, portfolios or 404 pages that want a paper or plaster feel fit the cracks of 05, and banners that need ornament packed edge to edge fit the circles of 04.
When You Study Generative Art Examples, Where Does Chance End and Rule Begin?
Scatter random dots evenly across a screen and the density is the same wherever you look, so there is no grain or clump for the eye to follow. In these nine scenes, chance only picks a starting point or chooses one candidate out of several, and the shape comes from a rule that says when growth must stop. A line in 01 halts once a point of another line comes within 4px, a circle in 04 freezes when its gap to a neighbor shrinks to 2px, and a crack in 05 ends the instant it meets a crack running at another angle.
The structure shows most clearly in what gets thrown away. 04 discarded 29,021 of 30,588 spot draws (94.9%) and planted circles only in the spots that survived. In the finished picture of 01, the closest distance between two different lines was exactly 4.00px, which is LINE_SEPARATION 8 multiplied by SEP_TEST 0.5, so however hard chance shook things, not one point slipped below the distance the rule set.
That is why a generative pattern maker of your own starts with the stopping condition rather than the choice of random generator. The nine scenes stop on distance (01 and 04), on attractors running out (02), on a node cap (03), on a different angle (05), on no undecided cell remaining (06), on a radius cap (07) and on no empty cell remaining (08). Each of those differences turns straight into a difference in the picture.
A Seeded Random Generator That Repeats the Same Picture, and a Grid That Lets Growing Lines Find Neighbors Fast
None of the nine sources calls Math.random, Date.now or new Date. Every random number comes from the one makeRng function below, which stirs a 32-bit integer with Math.imul and >>> and returns a value from 0 up to but not including 1, so the same seed replays the same sequence.
// Seeded random — the same seed gives the same sequence, and so the same picture again (32-bit integer mixing)
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;
};
}
// Next seed — each Redraw changes just one integer (five digits between 10007 and 99995)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
The Redraw button and Enter use nextSeed to make the next five-digit seed, and the Same seed button and R regrow the current seed from an empty board. Growing 01 with seeds 47101, 13161 and then 47101 again and hashing every point coordinate gives 1433911356, 239204140 and 1433911356, a numeric check that one seed means one picture and a new seed means a new one. Growth is split into 16ms ticks rather than frames, and the work per tick is a constant (01 STEPS_PER_FRAME 70, 06 COLLAPSE_PER_FRAME 2 and so on), so the scenes likely grow in the same order on screens with different refresh rates. That repeatability is the gap between generative art using code and a one-off random scatter that cannot be reproduced later.
Finding neighbors is the job of grid buckets. The screen is cut into cells (8px in 01, 60px in 02, 12px in 03, 34px in 04), every point goes into its own cell, and distance checks run only against points in the surrounding cells. A fully grown 01 holds 8,386 points, so checking each step against every point would have meant more than 8,000 comparisons per step.
01Flow Lines That Stop 4px Short of Any Other Line
Each corner of a 12px grid gets one direction from noise up front, and a line walks 2px at a time in both directions, steering by the direction half a step ahead, until another line's point comes within 4px. The spots 8px to either side of a finished line queue up as the next starting candidates, so the screen fills with evenly spaced grain that suits an about page that wants contour lines or a sense of wind. Seed 47101 finished in 135 ticks with 155 lines totaling 16,462px, and the capture showed 28.0% cumulative area, intensity 97.0 and 17/23 frames.

<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="Lines grow both ways along a noise-driven flow and stop once they come closer to another line than the set spacing. 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">Growing flow lines</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 makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
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;
}
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng, noise, o) {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) Length 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f, x, y) {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 Length 안)은 세지 않는다
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f, x, y) {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f, x, y) {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f, x, y) {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// Lines 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f) {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f, budget, seg) {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) {
flowStart(f, cx, cy);
}
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) {
f.fails = 0;
flowStart(f, rx, ry);
}
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) {
f.done = true;
}
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 Lines 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 노이즈 주파수(px 당) — 작을수록 소용돌이가 크고 느슨해진다
const FLOW_NOISE_SCALE = 0.0045;
// 선과 Lines 사이 간격(px) — 새 선의 출발점은 다른 선의 점과 이만큼 떨어져야 한다
const LINE_SEPARATION = 8;
// 한 걸음 Length(px)
const STEP_LENGTH = 2;
// 자라는 선은 간격의 이 비율보다 가까워지면 멈춘다 — 출발 간격보다 느슨하게 둬 Lines 끝이 너무 일찍 끊기지 않게
const SEP_TEST = 0.5;
// 방향을 미리 정해 두는 칸 크기(px)
const FIELD_CELL = 12;
// 한 틱(16ms)에 쓰는 일의 양(걸음 1, 출발점 검사 0.2) — 성장 속도이자 프레임당 계산량 상한
const STEPS_PER_FRAME = 70;
// Lines 하나의 점 수 상한 — 소용돌이 가운데를 끝없이 도는 선을 끊는다
const MAX_LINE_POINTS = 700;
const SEED0 = 47101;
const TXT_RUN = 'Growing flow lines';
function newFlow(s) {
const rng = makeRng(s);
const noise = makeNoise(rng);
return makeFlow(rng, noise, { sep: LINE_SEPARATION, test: SEP_TEST, step: STEP_LENGTH, scale: FLOW_NOISE_SCALE, cell: FIELD_CELL, maxPts: MAX_LINE_POINTS, margin: 6 });
}
let flow = newFlow(SEED0);
function plant(s) {
clearArt(INK);
flow = newFlow(s);
}
// 선분 색 — 진행 방향의 세로 성분으로 하늘색과 분홍 사이를 섞어, 같은 흐름은 같은 색 띠로 읽힌다
function drawSeg(ax, ay, bx, by) {
const t = (by - ay) / STEP_LENGTH * 0.5 + 0.5;
actx.strokeStyle = rgba(mix(PAL[0], PAL[1], clamp(t, 0, 1)), 0.92);
actx.lineWidth = 1.5;
actx.lineCap = 'round';
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function grow() { return flowStep(flow, STEPS_PER_FRAME, drawSeg); }
function paintFrame() {
blitArt();
// 지금 자라는 Lines 끝
if (flow.active) {
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.beginPath();
ctx.arc(flow.x, flow.y, 2.6, 0, TAU);
ctx.fill();
}
}
function statPairs() {
return [['Lines', String(flow.lines)], ['Length', Math.round(flow.length) + 'px']];
}
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);
}
};
})();// FlowFieldEvenLines - vanilla 판(01-gen-art-flow-field-even-streamlines) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FlowFieldEvenLines.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlowFieldEvenLines() {
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); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 격자 버킷 — 점 번호를 칸(한 변 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 FlowOpts = { sep: number; test: number; step: number; scale: number; cell: number; maxPts: number; margin: number };
type FlowSeg = (ax: number, ay: number, bx: number, by: number, line: number) => void;
type Flow = {
o: FlowOpts; rng: () => number; vx: Float64Array; vy: Float64Array; fcols: number; frows: number;
px: number[]; py: number[]; pl: number[]; po: number[]; grid: Buckets;
cand: number[]; candAt: number; line: number; dir: number; ord: number; x: number; y: number; sx: number; sy: number; pts: number[];
active: boolean; lines: number; length: number; fails: number; done: boolean;
};
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng: () => number, noise: Noise, o: FlowOpts): Flow {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 길이 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f: Flow, x: number, y: number): [number, number] {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
function flowTooClose(f: Flow, x: number, y: number, d: number, line: number, ord: number): boolean {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id: number): boolean {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) { return false; }
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f: Flow, x: number, y: number): boolean {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f: Flow, x: number, y: number): void {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f: Flow, x: number, y: number): void {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 선 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f: Flow): void {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f: Flow, budget: number, seg: FlowSeg): boolean {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) { flowStart(f, cx, cy); }
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) { f.fails = 0; flowStart(f, rx, ry); }
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) { f.done = true; }
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
} else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 노이즈 주파수(px 당) — 작을수록 소용돌이가 크고 느슨해진다
const FLOW_NOISE_SCALE = 0.0045;
// 선과 선 사이 간격(px) — 새 선의 출발점은 다른 선의 점과 이만큼 떨어져야 한다
const LINE_SEPARATION = 8;
// 한 걸음 길이(px)
const STEP_LENGTH = 2;
// 자라는 선은 간격의 이 비율보다 가까워지면 멈춘다 — 출발 간격보다 느슨하게 둬 선 끝이 너무 일찍 끊기지 않게
const SEP_TEST = 0.5;
// 방향을 미리 정해 두는 칸 크기(px)
const FIELD_CELL = 12;
// 한 틱(16ms)에 쓰는 일의 양(걸음 1, 출발점 검사 0.2) — 성장 속도이자 프레임당 계산량 상한
const STEPS_PER_FRAME = 70;
// 선 하나의 점 수 상한 — 소용돌이 가운데를 끝없이 도는 선을 끊는다
const MAX_LINE_POINTS = 700;
const SEED0 = 47101;
const TXT_RUN = '흐름 선이 자라는 중';
function newFlow(s: number): Flow {
const rng = makeRng(s);
const noise = makeNoise(rng);
return makeFlow(rng, noise, { sep: LINE_SEPARATION, test: SEP_TEST, step: STEP_LENGTH, scale: FLOW_NOISE_SCALE, cell: FIELD_CELL, maxPts: MAX_LINE_POINTS, margin: 6 });
}
let flow: Flow = newFlow(SEED0);
function plant(s: number): void {
clearArt(INK);
flow = newFlow(s);
}
// 선분 색 — 진행 방향의 세로 성분으로 하늘색과 분홍 사이를 섞어, 같은 흐름은 같은 색 띠로 읽힌다
function drawSeg(ax: number, ay: number, bx: number, by: number): void {
const t = (by - ay) / STEP_LENGTH * 0.5 + 0.5;
actx.strokeStyle = rgba(mix(PAL[0], PAL[1], clamp(t, 0, 1)), 0.92);
actx.lineWidth = 1.5;
actx.lineCap = 'round';
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function grow(): boolean { return flowStep(flow, STEPS_PER_FRAME, drawSeg); }
function paintFrame(): void {
blitArt();
// 지금 자라는 선 끝
if (flow.active) {
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.beginPath();
ctx.arc(flow.x, flow.y, 2.6, 0, TAU);
ctx.fill();
}
}
function statPairs(): [string, string][] {
return [['선', String(flow.lines)], ['길이', Math.round(flow.length) + 'px']];
}
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>
);
}01 · Lines That Follow a Flow Field While Keeping Their Distance — Measured area 28.044% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Is another point within radius d of (x, y)? Points right before and after on the same line (within twice the spacing) do not count
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
// One step — midpoint method that walks along the direction half a step ahead (cuts the error that drifts outside curves)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// If the front is blocked, go back to the start and grow the other way; if that is blocked too, the line is done
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
02Leaf Veins Branching Toward 900 Scattered Attractors
The seeded generator scatters 900 attractors inside a leaf shape, each attractor picks the nearest node within 60px, that node sprouts a 2.6px node along the average of its pulls, and any attractor within 6px of a new node is removed. Branch thickness follows the square root of each node's descendant count, thinning from the stem out to the leaf edge, so the growth itself tells a story on the about screen of a plant or eco brand. Seed 47102 removed every attractor by tick 103 and stopped at 794 nodes and 71 branch tips, and at 9.8% cumulative area, intensity 60.2 and 16/23 frames it was the only one of the nine under 10%.

<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 · See the password
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);
// If attractors pull from opposite sides and the average is near 0, this node skips growing this time
if (L < 0.2) {
continue;
}
// A tiny jitter — breaks symmetry so nodes do not stack on the same spot
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);
// Stop after VEIN_STALL_LIMIT ticks in a row of growth with no attractor removed — so it does not circle after attractors it cannot reach
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;
}
03A Closed Line That Folds Each Time Nodes Are Inserted
Every node on a closed line is drawn toward the midpoint of its two neighbors, held to them by a spring that tries to keep 4px, and pushed off by any node other than those neighbors that comes within 12px. Each tick adds nodes to edges longer than 5px and to edges picked by the seeded generator, and with no room left inside its oval fence the line folds into coral or brain-like ridges, a good header for science or medical content that wants an organic texture. Seed 47103 reached the 2,600-node cap at tick 214 with a perimeter of 5,611px, and the render measured 15.5% cumulative area, intensity 81.9 and 23/23 frames.

<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="Nodes on a closed line push each other away and new nodes are inserted into long edges, so the line folds like coral. 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">Growing and folding</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;
}
// 마디끼리 밀어내는 반경(px) — 이 안에 든 마디는 거리에 반비례해 밀린다
const REPEL_RADIUS = 12;
// Nodes 사이가 이보다 벌어지면 가운데에 새 마디를 끼운다(px)
const MAX_EDGE_LENGTH = 5;
// 양옆 마디의 가운데 쪽으로 끌어당기는 비율 — 클수록 선이 매끈해진다
const ALIGN_FORCE = 0.4;
// 밀어내는 힘의 크기
const REPEL_FORCE = 0.5;
// 이웃 마디끼리 잇는 용수철 — 쉬는 Length(px)보다 짧으면 밀고 길면 당긴다
const SPRING_REST = 4;
const SPRING_FORCE = 0.3;
// 한 틱에 아무 변에나 끼우는 성장 Nodes 수 — 마디가 늘면 용수철이 선을 늘이려 하고, 울타리 안에서 늘어날 자리가 없으니 접힌다
const GROWTH_PER_FRAME = 12;
// 한 틱에 끼우는 Nodes 수 상한(긴 변 나누기 + 성장)과 전체 Nodes 수 상한 — 프레임당 계산량 상한
const SPLITS_PER_FRAME = 24;
const MAX_NODES = 2600;
// 자랄 수 있는 타원 울타리
const BOUND_CX = 300;
const BOUND_CY = 150;
const BOUND_RX = 168;
const BOUND_RY = 124;
const START_NODES = 24;
const START_R = 22;
const SEED0 = 47103;
const TXT_RUN = 'Growing and folding';
let xs = [];
let ys = [];
let lrng = makeRng(SEED0);
let splitFrom = 0;
const grid = makeBuckets(REPEL_RADIUS);
function plant(s) {
clearArt(INK);
lrng = makeRng(s);
xs = [];
ys = [];
splitFrom = 0;
for (let i = 0; i < START_NODES; i += 1) {
const a = (i / START_NODES) * TAU;
const r = START_R * (1 + (lrng() - 0.5) * 0.3);
xs.push(BOUND_CX + Math.cos(a) * r);
ys.push(BOUND_CY + Math.sin(a) * r);
}
}
// 힘 합 — 양옆 가운데로 끌기 + 반경 안 이웃(격자 근방만) 밀기 + 타원 밖이면 안으로
function relax() {
const n = xs.length;
bucketsClear(grid);
for (let i = 0; i < n; i += 1) {
bucketAdd(grid, xs[i], ys[i], i);
}
const nx = new Array(n);
const ny = new Array(n);
const R = REPEL_RADIUS;
for (let i = 0; i < n; i += 1) {
const x = xs[i];
const y = ys[i];
const pv = (i + n - 1) % n;
const nt = (i + 1) % n;
let fx = ((xs[pv] + xs[nt]) * 0.5 - x) * ALIGN_FORCE;
let fy = ((ys[pv] + ys[nt]) * 0.5 - y) * ALIGN_FORCE;
const nbs = [pv, nt];
for (let k = 0; k < 2; k += 1) {
const j = nbs[k];
const dx = xs[j] - x;
const dy = ys[j] - y;
const d = Math.hypot(dx, dy) || 1e-6;
const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
fx += dx * pull;
fy += dy * pull;
}
bucketNear(grid, x, y, R, function (j) {
if (j === i || j === pv || j === nt) {
return false;
}
const dx = x - xs[j];
const dy = y - ys[j];
const d2 = dx * dx + dy * dy;
if (d2 >= R * R || d2 < 1e-9) {
return false;
}
const d = Math.sqrt(d2);
const push = ((R - d) / R) * REPEL_FORCE;
fx += (dx / d) * push;
fy += (dy / d) * push;
return false;
});
const ex = (x - BOUND_CX) / BOUND_RX;
const ey = (y - BOUND_CY) / BOUND_RY;
const e = ex * ex + ey * ey;
if (e > 1) {
fx -= (x - BOUND_CX) * (e - 1) * 0.5;
fy -= (y - BOUND_CY) * (e - 1) * 0.5;
}
nx[i] = x + fx;
ny[i] = y + fy;
}
xs = nx;
ys = ny;
}
// 긴 변 나누기 — 시작 자리를 틱마다 옮겨 가며 MAX_EDGE_LENGTH 보다 긴 변 가운데에 마디를 끼운다
function splitEdges() {
let made = 0;
const n0 = xs.length;
let i = splitFrom % n0;
for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
const j = (i + 1) % xs.length;
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
// 가운데에서 아주 조금 옆으로 — 대칭이 깨져야 주름이 접힌다
const jit = (lrng() - 0.5) * 0.4;
xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
made += 1;
i += 1;
}
i = (i + 1) % xs.length;
}
splitFrom = i + 37;
// 성장 — 남은 몫만큼 시드 난수로 고른 변 가운데에 마디를 더 끼운다
for (let g = 0; g < GROWTH_PER_FRAME && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; g += 1) {
const a = Math.floor(lrng() * xs.length);
const b = (a + 1) % xs.length;
xs.splice(a + 1, 0, (xs[a] + xs[b]) * 0.5);
ys.splice(a + 1, 0, (ys[a] + ys[b]) * 0.5);
made += 1;
}
}
function grow() {
relax();
splitEdges();
return xs.length >= MAX_NODES;
}
function perimeter() {
let s = 0;
for (let i = 0; i < xs.length; i += 1) {
const j = (i + 1) % xs.length;
s += Math.hypot(xs[j] - xs[i], ys[j] - ys[i]);
}
return s;
}
function paintFrame() {
blitArt();
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
for (let i = 1; i < xs.length; i += 1) {
ctx.lineTo(xs[i], ys[i]);
}
ctx.closePath();
ctx.fillStyle = rgba(mix(PAL[1], PAL[7], 0.4), 0.9);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.95);
ctx.lineWidth = 1.3;
ctx.lineJoin = 'round';
ctx.stroke();
}
function statPairs() {
return [['Nodes', String(xs.length)], ['Perimeter', Math.round(perimeter()) + 'px']];
}
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);
}
};
})();// FoldingLineGrowth - vanilla 판(03-gen-art-folding-line-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FoldingLineGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FoldingLineGrowth() {
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;
}
// 마디끼리 밀어내는 반경(px) — 이 안에 든 마디는 거리에 반비례해 밀린다
const REPEL_RADIUS = 12;
// 마디 사이가 이보다 벌어지면 가운데에 새 마디를 끼운다(px)
const MAX_EDGE_LENGTH = 5;
// 양옆 마디의 가운데 쪽으로 끌어당기는 비율 — 클수록 선이 매끈해진다
const ALIGN_FORCE = 0.4;
// 밀어내는 힘의 크기
const REPEL_FORCE = 0.5;
// 이웃 마디끼리 잇는 용수철 — 쉬는 길이(px)보다 짧으면 밀고 길면 당긴다
const SPRING_REST = 4;
const SPRING_FORCE = 0.3;
// 한 틱에 아무 변에나 끼우는 성장 마디 수 — 마디가 늘면 용수철이 선을 늘이려 하고, 울타리 안에서 늘어날 자리가 없으니 접힌다
const GROWTH_PER_FRAME = 12;
// 한 틱에 끼우는 마디 수 상한(긴 변 나누기 + 성장)과 전체 마디 수 상한 — 프레임당 계산량 상한
const SPLITS_PER_FRAME = 24;
const MAX_NODES = 2600;
// 자랄 수 있는 타원 울타리
const BOUND_CX = 300;
const BOUND_CY = 150;
const BOUND_RX = 168;
const BOUND_RY = 124;
const START_NODES = 24;
const START_R = 22;
const SEED0 = 47103;
const TXT_RUN = '주름이 접히며 자라는 중';
let xs: number[] = [];
let ys: number[] = [];
let lrng: () => number = makeRng(SEED0);
let splitFrom = 0;
const grid: Buckets = makeBuckets(REPEL_RADIUS);
function plant(s: number): void {
clearArt(INK);
lrng = makeRng(s);
xs = [];
ys = [];
splitFrom = 0;
for (let i = 0; i < START_NODES; i += 1) {
const a = (i / START_NODES) * TAU;
const r = START_R * (1 + (lrng() - 0.5) * 0.3);
xs.push(BOUND_CX + Math.cos(a) * r);
ys.push(BOUND_CY + Math.sin(a) * r);
}
}
// 힘 합 — 양옆 가운데로 끌기 + 반경 안 이웃(격자 근방만) 밀기 + 타원 밖이면 안으로
function relax(): void {
const n = xs.length;
bucketsClear(grid);
for (let i = 0; i < n; i += 1) { bucketAdd(grid, xs[i], ys[i], i); }
const nx: number[] = new Array(n);
const ny: number[] = new Array(n);
const R = REPEL_RADIUS;
for (let i = 0; i < n; i += 1) {
const x = xs[i];
const y = ys[i];
const pv = (i + n - 1) % n;
const nt = (i + 1) % n;
let fx = ((xs[pv] + xs[nt]) * 0.5 - x) * ALIGN_FORCE;
let fy = ((ys[pv] + ys[nt]) * 0.5 - y) * ALIGN_FORCE;
const nbs = [pv, nt];
for (let k = 0; k < 2; k += 1) {
const j = nbs[k];
const dx = xs[j] - x;
const dy = ys[j] - y;
const d = Math.hypot(dx, dy) || 1e-6;
const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
fx += dx * pull;
fy += dy * pull;
}
bucketNear(grid, x, y, R, function (j: number): boolean {
if (j === i || j === pv || j === nt) { return false; }
const dx = x - xs[j];
const dy = y - ys[j];
const d2 = dx * dx + dy * dy;
if (d2 >= R * R || d2 < 1e-9) { return false; }
const d = Math.sqrt(d2);
const push = ((R - d) / R) * REPEL_FORCE;
fx += (dx / d) * push;
fy += (dy / d) * push;
return false;
});
const ex = (x - BOUND_CX) / BOUND_RX;
const ey = (y - BOUND_CY) / BOUND_RY;
const e = ex * ex + ey * ey;
if (e > 1) {
fx -= (x - BOUND_CX) * (e - 1) * 0.5;
fy -= (y - BOUND_CY) * (e - 1) * 0.5;
}
nx[i] = x + fx;
ny[i] = y + fy;
}
xs = nx;
ys = ny;
}
// 긴 변 나누기 — 시작 자리를 틱마다 옮겨 가며 MAX_EDGE_LENGTH 보다 긴 변 가운데에 마디를 끼운다
function splitEdges(): void {
let made = 0;
const n0 = xs.length;
let i = splitFrom % n0;
for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
const j = (i + 1) % xs.length;
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
// 가운데에서 아주 조금 옆으로 — 대칭이 깨져야 주름이 접힌다
const jit = (lrng() - 0.5) * 0.4;
xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
made += 1;
i += 1;
}
i = (i + 1) % xs.length;
}
splitFrom = i + 37;
// 성장 — 남은 몫만큼 시드 난수로 고른 변 가운데에 마디를 더 끼운다
for (let g = 0; g < GROWTH_PER_FRAME && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; g += 1) {
const a = Math.floor(lrng() * xs.length);
const b = (a + 1) % xs.length;
xs.splice(a + 1, 0, (xs[a] + xs[b]) * 0.5);
ys.splice(a + 1, 0, (ys[a] + ys[b]) * 0.5);
made += 1;
}
}
function grow(): boolean {
relax();
splitEdges();
return xs.length >= MAX_NODES;
}
function perimeter(): number {
let s = 0;
for (let i = 0; i < xs.length; i += 1) {
const j = (i + 1) % xs.length;
s += Math.hypot(xs[j] - xs[i], ys[j] - ys[i]);
}
return s;
}
function paintFrame(): void {
blitArt();
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
for (let i = 1; i < xs.length; i += 1) { ctx.lineTo(xs[i], ys[i]); }
ctx.closePath();
ctx.fillStyle = rgba(mix(PAL[1], PAL[7], 0.4), 0.9);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.95);
ctx.lineWidth = 1.3;
ctx.lineJoin = 'round';
ctx.stroke();
}
function statPairs(): [string, string][] {
return [['마디', String(xs.length)], ['둘레', Math.round(perimeter()) + 'px']];
}
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>
);
}03 · A Line That Folds as It Grows by Inserting Nodes — Measured area 15.466% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
for (let k = 0; k < 2; k += 1) {
const j = nbs[k];
const dx = xs[j] - x;
const dy = ys[j] - y;
const d = Math.hypot(dx, dy) || 1e-6;
const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
fx += dx * pull;
fy += dy * pull;
}
bucketNear(grid, x, y, R, function (j) {
if (j === i || j === pv || j === nt) {
return false;
}
const dx = x - xs[j];
const dy = y - ys[j];
const d2 = dx * dx + dy * dy;
if (d2 >= R * R || d2 < 1e-9) {
return false;
}
const d = Math.sqrt(d2);
const push = ((R - d) / R) * REPEL_FORCE;
fx += (dx / d) * push;
fy += (dy / d) * push;
return false;
});
// Split long edges — shifting the start index every tick, insert a node in the middle of any edge longer than MAX_EDGE_LENGTH
function splitEdges() {
let made = 0;
const n0 = xs.length;
let i = splitFrom % n0;
for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
const j = (i + 1) % xs.length;
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
// Just slightly off the middle — folds form only once symmetry breaks
const jit = (lrng() - 0.5) * 0.4;
xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
made += 1;
i += 1;
}
i = (i + 1) % xs.length;
}
splitFrom = i + 37;
04Circle Packing That Inflates Until Only a 2px Gap Is Left
Each tick draws 60 random spots and plants a 1px circle only where a 2px gap remains to every other circle and the border, then grows each planted circle by 0.8px per tick and bakes it into the artwork the moment it runs out of room. Small circles filling the gaps between large ones play out in plain view, which suits event posters or promo banners that want ornament packed edge to edge. Seed 47104 ran for 565 ticks and covered 46.1% of the screen with 1,567 circles, and the capture read 44.2% cumulative area, intensity 92.8 and 23/23 frames.

<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="Circles planted in empty spots inflate and stop when they reach a neighbor or the border, filling the frame. 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">Circles inflating to fill the space</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 makePack(rng, o) {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 Circles(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
function packStep(p, onStop) {
if (p.done) {
return true;
}
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
}
else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
}
// 한 틱에 반지름이 자라는 양(px)
const GROW_RATE = 0.8;
// 한 틱에 새 Circles 자리를 뽑아 보는 횟수 — 겹치면 버리는 거절 표본
const SPAWN_ATTEMPTS = 60;
// 원과 Circles, 원과 테두리 사이에 남기는 틈(px) — 틈이 이만큼으로 줄면 그 원은 멈춘다
const CIRCLE_GAP = 2;
// Circles 하나의 반지름 상한과 처음 반지름(px)
const MAX_RADIUS = 34;
const START_RADIUS = 1;
// 한 틱에 새로 심는 Circles 수 상한 — 프레임당 계산량 상한
const SPAWN_PER_FRAME = 8;
// 이만큼 연달아 한 개도 못 심으면 빈자리가 다 찼다고 본다(틱)
const FAIL_TICKS = 8;
const SEED0 = 47104;
const TXT_RUN = 'Circles inflating to fill the space';
function newPack(s) {
return makePack(makeRng(s), {
grow: GROW_RATE, attempts: SPAWN_ATTEMPTS, gap: CIRCLE_GAP, maxR: MAX_RADIUS, startR: START_RADIUS,
perTick: SPAWN_PER_FRAME, failTicks: FAIL_TICKS, x0: 4, y0: 4, x1: W - 4, y1: H - 4,
blocked: function () { return false; },
});
}
let pack = newPack(SEED0);
let stopped = 0;
function plant(s) {
clearArt(INK);
pack = newPack(s);
stopped = 0;
}
// Circles 하나 — 바깥 면과, 반지름 62% 자리의 옅은 안쪽 Circles
function disc(c, i, alpha) {
const col = PAL[pack.col[i]];
c.fillStyle = rgba(col, alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
c.fill();
if (pack.cr[i] > 3) {
c.fillStyle = rgba(mix(col, PAPER, 0.45), alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i] * 0.62, 0, TAU);
c.fill();
}
}
// 멈춘 원은 그림판에 한 번만 굳혀 그린다
function settleDisc(i) {
disc(actx, i, 1);
stopped += 1;
}
function grow() { return packStep(pack, settleDisc); }
function paintFrame() {
blitArt();
// 아직 Growing — 반투명 면 + 밝은 테두리
for (let k = 0; k < pack.growing.length; k += 1) {
const i = pack.growing[k];
disc(ctx, i, 0.7);
ctx.strokeStyle = rgba(PAPER, 0.9);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
ctx.stroke();
}
}
function statPairs() {
return [['Circles', String(pack.cx.length)], ['Growing', String(pack.growing.length)]];
}
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);
}
};
})();// CirclePackInflate - vanilla 판(04-gen-art-circle-pack-inflate) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CirclePackInflate.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CirclePackInflate() {
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;
}
// 부푸는 원 채우기 — 빈자리를 몇 번 골라 원을 심고 틱마다 반지름을 키우다가, 이웃 원·테두리와의 틈이 gap 에 닿으면 그 원만 멈춘다
type PackOpts = {
grow: number; attempts: number; gap: number; maxR: number; startR: number; perTick: number; failTicks: number;
x0: number; y0: number; x1: number; y1: number; blocked: (x: number, y: number, r: number) => boolean;
};
type Pack = {
o: PackOpts; rng: () => number; cx: number[]; cy: number[]; cr: number[]; col: number[];
growing: number[]; grid: Buckets; fails: number; tries: number; rejects: number; done: boolean;
};
function makePack(rng: () => number, o: PackOpts): Pack {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 원(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p: Pack, x: number, y: number, r: number, self: number): boolean {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) { return false; }
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j: number): boolean {
if (j === self) { return false; }
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) { return false; }
return !p.o.blocked(x, y, r + g);
}
function packStep(p: Pack, onStop: (i: number) => void): boolean {
if (p.done) { return true; }
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
} else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next: number[] = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
} else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) { p.done = true; }
return p.done;
}
// 한 틱에 반지름이 자라는 양(px)
const GROW_RATE = 0.8;
// 한 틱에 새 원 자리를 뽑아 보는 횟수 — 겹치면 버리는 거절 표본
const SPAWN_ATTEMPTS = 60;
// 원과 원, 원과 테두리 사이에 남기는 틈(px) — 틈이 이만큼으로 줄면 그 원은 멈춘다
const CIRCLE_GAP = 2;
// 원 하나의 반지름 상한과 처음 반지름(px)
const MAX_RADIUS = 34;
const START_RADIUS = 1;
// 한 틱에 새로 심는 원 수 상한 — 프레임당 계산량 상한
const SPAWN_PER_FRAME = 8;
// 이만큼 연달아 한 개도 못 심으면 빈자리가 다 찼다고 본다(틱)
const FAIL_TICKS = 8;
const SEED0 = 47104;
const TXT_RUN = '원이 부풀며 자리를 채우는 중';
function newPack(s: number): Pack {
return makePack(makeRng(s), {
grow: GROW_RATE, attempts: SPAWN_ATTEMPTS, gap: CIRCLE_GAP, maxR: MAX_RADIUS, startR: START_RADIUS,
perTick: SPAWN_PER_FRAME, failTicks: FAIL_TICKS, x0: 4, y0: 4, x1: W - 4, y1: H - 4,
blocked: function (): boolean { return false; },
});
}
let pack: Pack = newPack(SEED0);
let stopped = 0;
function plant(s: number): void {
clearArt(INK);
pack = newPack(s);
stopped = 0;
}
// 원 하나 — 바깥 면과, 반지름 62% 자리의 옅은 안쪽 원
function disc(c: CanvasRenderingContext2D, i: number, alpha: number): void {
const col = PAL[pack.col[i]];
c.fillStyle = rgba(col, alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
c.fill();
if (pack.cr[i] > 3) {
c.fillStyle = rgba(mix(col, PAPER, 0.45), alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i] * 0.62, 0, TAU);
c.fill();
}
}
// 멈춘 원은 그림판에 한 번만 굳혀 그린다
function settleDisc(i: number): void {
disc(actx, i, 1);
stopped += 1;
}
function grow(): boolean { return packStep(pack, settleDisc); }
function paintFrame(): void {
blitArt();
// 아직 자라는 원 — 반투명 면 + 밝은 테두리
for (let k = 0; k < pack.growing.length; k += 1) {
const i = pack.growing[k];
disc(ctx, i, 0.7);
ctx.strokeStyle = rgba(PAPER, 0.9);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
ctx.stroke();
}
}
function statPairs(): [string, string][] {
return [['원', String(pack.cx.length)], ['자라는 원', String(pack.growing.length)]];
}
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>
);
}04 · Circle Packing Where Circles Inflate Until They Touch — Measured area 44.177% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Can a circle of radius r sit at (x, y)? Checks the border, neighbor circles in the grid buckets (center distance < r + neighbor r + gap), then blocked spots, in that order
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
// Grow — if the radius one step larger still fits, grow; otherwise this circle stops here
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
05Right-Angle Cracks That Halt at a Crack of Another Angle
A board stores the angle of the crack that passed through each pixel, a crack walks 0.5px at a time over empty pixels or pixels within 5° of its own angle and stops at any other angle, and every stopped crack sends out two new ones from a point on an existing crack at a right angle shaken by up to 4°. Beside each crack it measures the empty run to the next crack and stamps 10 faint grains of sand inside it, which gives the gaps a grain that suits a portfolio backdrop with a paper or plaster feel. Seed 47105 hit the 320-crack cap at tick 100 with 14,337 pixels carrying an angle, and the capture read 42.4% cumulative area, intensity 56.6 and 17/23 frames.

<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="Cracks walk straight and stop at other cracks, new ones branch off at nearly right angles, and a sandy grain builds up beside them. 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">Cracks branching at right angles</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);
}
}
// Cracks 개수 상한 — 처음 금과 갈라져 나온 금을 모두 센다
const CRACK_MAX = 320;
// 새 금이 직각(±90°)에서 벗어나는 흔들림 폭(도)
const BRANCH_ANGLE_JITTER = 4;
// Cracks 한 걸음마다 법선 방향으로 흩뿌리는 모래알 수
const SAND_GRAINS = 10;
// 처음 각도를 심어 두는 자리 수와 처음 Cracks 수
const SEED_SITES = 12;
const START_CRACKS = 6;
// 동시에 Growing 수 상한, 한 걸음 Length(px), 한 틱에 금마다 걷는 걸음 수 — 프레임당 계산량 상한
const ACTIVE_MAX = 36;
const CRACK_STEP = 0.5;
const STEPS_PER_FRAME = 14;
// 같은 금으로 치는 각도 차(도) — 이보다 다른 각도가 적힌 칸에 닿으면 멈춘다
const ANGLE_TOLERANCE = 5;
// 출발 직후 이 걸음 수 동안은 충돌을 보지 않는다 — 갈라져 나온 자리(부모 Cracks 칸)에서 바로 멈추지 않게
const START_GRACE = 4;
// 모래를 뿌릴 빈칸을 법선으로 훑는 최대 거리(px)
const SAND_REACH = 48;
const EMPTY = 10001;
const SEED0 = 47105;
const TXT_RUN = 'Cracks branching at right angles';
// 각도 격자 — 화소마다 그 자리를 지나간 금의 각도(도). 아직 금이 없으면 EMPTY
const cgrid = new Float32Array(W * H);
// 각도가 적힌 화소 번호 목록 — 새 금의 출발점을 기존 Cracks 위에서 바로 고른다
let marks = [];
let cracks = [];
let started = 0;
let crng = makeRng(SEED0);
function sandStyles(col) {
const out = [];
for (let i = 0; i < SAND_GRAINS; i += 1) {
out.push(rgba(col, 0.34 * (1 - i / SAND_GRAINS)));
}
return out;
}
// 기존 Cracks 위 한 점에서 거의 직각으로 새 금을 낸다
function makeCrack() {
if (started >= CRACK_MAX || cracks.length >= ACTIVE_MAX) {
return;
}
if (!marks.length) {
return;
}
const found = marks[Math.floor(crng() * marks.length)];
const sgn = crng() < 0.5 ? -1 : 1;
const t = cgrid[found] + sgn * (90 + (crng() * 2 - 1) * BRANCH_ANGLE_JITTER);
const x = (found % W) + 0.61 * Math.cos(t * DEG);
const y = Math.floor(found / W) + 0.61 * Math.sin(t * DEG);
cracks.push({ x: x, y: y, t: t, steps: 0, g: crng(), sand: sandStyles(PAL[Math.floor(crng() * PAL.length)]), alive: true });
started += 1;
}
function plant(s) {
crng = makeRng(s);
cgrid.fill(EMPTY);
clearArt(PAPER);
cracks = [];
marks = [];
started = 0;
for (let k = 0; k < SEED_SITES; k += 1) {
const i = Math.floor(crng() * W) + Math.floor(crng() * H) * W;
cgrid[i] = Math.floor(crng() * 360);
marks.push(i);
}
for (let k = 0; k < START_CRACKS; k += 1) {
makeCrack();
}
}
// 법선 방향 모래 — 금에서 옆으로 빈칸을 따라가 다른 Cracks(또는 테두리)까지의 거리를 재고, 그 안쪽에 옅은 알갱이를 겹쳐 찍는다
function paintSand(c) {
const nxv = Math.cos((c.t + 90) * DEG);
const nyv = Math.sin((c.t + 90) * DEG);
let d = 1;
for (; d < SAND_REACH; d += 1) {
const px = Math.floor(c.x + nxv * d);
const py = Math.floor(c.y + nyv * d);
if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
break;
}
}
c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
for (let i = 0; i < SAND_GRAINS; i += 1) {
const k = (d * c.g * i) / SAND_GRAINS;
actx.fillStyle = c.sand[i];
actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
}
}
function moveCrack(c) {
c.x += CRACK_STEP * Math.cos(c.t * DEG);
c.y += CRACK_STEP * Math.sin(c.t * DEG);
c.steps += 1;
const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
c.alive = false;
return;
}
const i = cy * W + cx;
const g = cgrid[i];
if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
if (g >= EMPTY) {
marks.push(i);
}
cgrid[i] = c.t;
paintSand(c);
actx.fillStyle = rgba(INK, 0.85);
actx.fillRect(c.x, c.y, 1, 1);
}
else if (c.steps > START_GRACE) {
// 각도가 다른 금에 닿았다 — 여기서 멈춘다
c.alive = false;
}
}
function grow() {
for (let k = 0; k < cracks.length; k += 1) {
const c = cracks[k];
for (let s = 0; s < STEPS_PER_FRAME && c.alive; s += 1) {
moveCrack(c);
}
}
let ended = 0;
cracks = cracks.filter(function (c) { if (!c.alive) {
ended += 1;
} return c.alive; });
// 멈춘 Cracks 하나마다 새 Cracks 둘 — 상한(CRACK_MAX)까지 금이 늘어난다
for (let e = 0; e < ended; e += 1) {
makeCrack();
makeCrack();
}
return cracks.length === 0;
}
function paintFrame() {
blitArt();
// 지금 Growing 끝
ctx.fillStyle = rgba(INK, 0.9);
for (let k = 0; k < cracks.length; k += 1) {
ctx.beginPath();
ctx.arc(cracks[k].x, cracks[k].y, 1.8, 0, TAU);
ctx.fill();
}
}
function statPairs() {
return [['Cracks', started + ' / ' + CRACK_MAX], ['Growing', String(cracks.length)]];
}
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);
}
};
})();// CrackRightAngleSpread - vanilla 판(05-gen-art-crack-right-angle-spread) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CrackRightAngleSpread.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CrackRightAngleSpread() {
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); }
}
// 금 개수 상한 — 처음 금과 갈라져 나온 금을 모두 센다
const CRACK_MAX = 320;
// 새 금이 직각(±90°)에서 벗어나는 흔들림 폭(도)
const BRANCH_ANGLE_JITTER = 4;
// 금 한 걸음마다 법선 방향으로 흩뿌리는 모래알 수
const SAND_GRAINS = 10;
// 처음 각도를 심어 두는 자리 수와 처음 금 수
const SEED_SITES = 12;
const START_CRACKS = 6;
// 동시에 자라는 금 수 상한, 한 걸음 길이(px), 한 틱에 금마다 걷는 걸음 수 — 프레임당 계산량 상한
const ACTIVE_MAX = 36;
const CRACK_STEP = 0.5;
const STEPS_PER_FRAME = 14;
// 같은 금으로 치는 각도 차(도) — 이보다 다른 각도가 적힌 칸에 닿으면 멈춘다
const ANGLE_TOLERANCE = 5;
// 출발 직후 이 걸음 수 동안은 충돌을 보지 않는다 — 갈라져 나온 자리(부모 금 칸)에서 바로 멈추지 않게
const START_GRACE = 4;
// 모래를 뿌릴 빈칸을 법선으로 훑는 최대 거리(px)
const SAND_REACH = 48;
const EMPTY = 10001;
const SEED0 = 47105;
const TXT_RUN = '금이 직각으로 갈라지는 중';
type Crack = { x: number; y: number; t: number; steps: number; g: number; sand: string[]; alive: boolean };
// 각도 격자 — 화소마다 그 자리를 지나간 금의 각도(도). 아직 금이 없으면 EMPTY
const cgrid = new Float32Array(W * H);
// 각도가 적힌 화소 번호 목록 — 새 금의 출발점을 기존 금 위에서 바로 고른다
let marks: number[] = [];
let cracks: Crack[] = [];
let started = 0;
let crng: () => number = makeRng(SEED0);
function sandStyles(col: RGB): string[] {
const out: string[] = [];
for (let i = 0; i < SAND_GRAINS; i += 1) { out.push(rgba(col, 0.34 * (1 - i / SAND_GRAINS))); }
return out;
}
// 기존 금 위 한 점에서 거의 직각으로 새 금을 낸다
function makeCrack(): void {
if (started >= CRACK_MAX || cracks.length >= ACTIVE_MAX) { return; }
if (!marks.length) { return; }
const found = marks[Math.floor(crng() * marks.length)];
const sgn = crng() < 0.5 ? -1 : 1;
const t = cgrid[found] + sgn * (90 + (crng() * 2 - 1) * BRANCH_ANGLE_JITTER);
const x = (found % W) + 0.61 * Math.cos(t * DEG);
const y = Math.floor(found / W) + 0.61 * Math.sin(t * DEG);
cracks.push({ x: x, y: y, t: t, steps: 0, g: crng(), sand: sandStyles(PAL[Math.floor(crng() * PAL.length)]), alive: true });
started += 1;
}
function plant(s: number): void {
crng = makeRng(s);
cgrid.fill(EMPTY);
clearArt(PAPER);
cracks = [];
marks = [];
started = 0;
for (let k = 0; k < SEED_SITES; k += 1) {
const i = Math.floor(crng() * W) + Math.floor(crng() * H) * W;
cgrid[i] = Math.floor(crng() * 360);
marks.push(i);
}
for (let k = 0; k < START_CRACKS; k += 1) { makeCrack(); }
}
// 법선 방향 모래 — 금에서 옆으로 빈칸을 따라가 다른 금(또는 테두리)까지의 거리를 재고, 그 안쪽에 옅은 알갱이를 겹쳐 찍는다
function paintSand(c: Crack): void {
const nxv = Math.cos((c.t + 90) * DEG);
const nyv = Math.sin((c.t + 90) * DEG);
let d = 1;
for (; d < SAND_REACH; d += 1) {
const px = Math.floor(c.x + nxv * d);
const py = Math.floor(c.y + nyv * d);
if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) { break; }
}
c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
for (let i = 0; i < SAND_GRAINS; i += 1) {
const k = (d * c.g * i) / SAND_GRAINS;
actx.fillStyle = c.sand[i];
actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
}
}
function moveCrack(c: Crack): void {
c.x += CRACK_STEP * Math.cos(c.t * DEG);
c.y += CRACK_STEP * Math.sin(c.t * DEG);
c.steps += 1;
const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
if (cx < 0 || cx >= W || cy < 0 || cy >= H) { c.alive = false; return; }
const i = cy * W + cx;
const g = cgrid[i];
if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
if (g >= EMPTY) { marks.push(i); }
cgrid[i] = c.t;
paintSand(c);
actx.fillStyle = rgba(INK, 0.85);
actx.fillRect(c.x, c.y, 1, 1);
} else if (c.steps > START_GRACE) {
// 각도가 다른 금에 닿았다 — 여기서 멈춘다
c.alive = false;
}
}
function grow(): boolean {
for (let k = 0; k < cracks.length; k += 1) {
const c = cracks[k];
for (let s = 0; s < STEPS_PER_FRAME && c.alive; s += 1) { moveCrack(c); }
}
let ended = 0;
cracks = cracks.filter(function (c: Crack): boolean { if (!c.alive) { ended += 1; } return c.alive; });
// 멈춘 금 하나마다 새 금 둘 — 상한(CRACK_MAX)까지 금이 늘어난다
for (let e = 0; e < ended; e += 1) { makeCrack(); makeCrack(); }
return cracks.length === 0;
}
function paintFrame(): void {
blitArt();
// 지금 자라는 금 끝
ctx.fillStyle = rgba(INK, 0.9);
for (let k = 0; k < cracks.length; k += 1) {
ctx.beginPath();
ctx.arc(cracks[k].x, cracks[k].y, 1.8, 0, TAU);
ctx.fill();
}
}
function statPairs(): [string, string][] {
return [['금', started + ' / ' + CRACK_MAX], ['자라는 금', String(cracks.length)]];
}
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>
);
}05 · Cracks That Spread by Branching at Right Angles — Measured area 42.388% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Sand along the normal — follow empty pixels sideways from the crack to measure the distance to another crack (or the border), then stamp faint grains inside it
function paintSand(c) {
const nxv = Math.cos((c.t + 90) * DEG);
const nyv = Math.sin((c.t + 90) * DEG);
let d = 1;
for (; d < SAND_REACH; d += 1) {
const px = Math.floor(c.x + nxv * d);
const py = Math.floor(c.y + nyv * d);
if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
break;
}
}
c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
for (let i = 0; i < SAND_GRAINS; i += 1) {
const k = (d * c.g * i) / SAND_GRAINS;
actx.fillStyle = c.sand[i];
actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
}
}
function moveCrack(c) {
c.x += CRACK_STEP * Math.cos(c.t * DEG);
c.y += CRACK_STEP * Math.sin(c.t * DEG);
c.steps += 1;
const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
c.alive = false;
return;
}
const i = cy * W + cx;
const g = cgrid[i];
if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
if (g >= EMPTY) {
marks.push(i);
}
cgrid[i] = c.t;
paintSand(c);
actx.fillStyle = rgba(INK, 0.85);
actx.fillRect(c.x, c.y, 1, 1);
}
else if (c.steps > START_GRACE) {
// Hit a crack with a different angle — stop here
c.alive = false;
}
06Matching Tiles That Keep Only Pieces Whose Edges Agree
Every cell of a 24×15 board starts with all 18 tiles as candidates, each tile carrying grass, road or stream edges, and the cell with the fewest left is settled by a weighted seeded pick whose effect a queue carries through the neighbors' candidates until nothing changes. Unsettled cells are drawn as fog that thickens with the share of candidates still open, so you watch the constraints spread, which fits a map-style game screen or the wait screen of a route-finding service. Demo seed 66110 hit a cell with zero candidates at 912ms, refilled the board once from scratch and completed all 360 cells at 3,664ms, and the render gave 50.6% cumulative area, intensity 83.0 and 23/23 frames.

<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="Starting from the cell with the fewest options, tiles whose edges match their neighbors fill a map of roads and streams, restarting from scratch on a contradiction. 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">Picking tiles that match neighbors</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);
}
}
// 타일마다 네 변(위·오른쪽·아래·왼쪽)의 종류 — 0 풀밭, 1 길, 2 물길. 맞닿는 두 변의 숫자가 같아야 이웃이 된다.
// 길과 물길이 엇갈리는 타일(다리)은 없다 — 그래서 길과 물길이 한 칸에서 만나야 하는 자리가 생기면 모순이 난다
const TILE_EDGES = [
'0000',
'0101', '1010',
'1100', '0110', '0011', '1001',
'1110', '0111', '1011', '1101',
'1111',
'0202', '2020',
'2200', '0220', '0022', '2002',
];
// 고를 때의 가중치 — 같은 순서. 풀밭이 가장 넓게
const TILE_WEIGHTS = [
6,
2, 2,
1, 1, 1, 1,
0.6, 0.6, 0.6, 0.6,
0.4,
1.6, 1.6,
0.8, 0.8, 0.8, 0.8,
];
// 한 틱에 고르는(무너뜨리는) 칸 수 — 성장 속도이자 프레임당 계산량 상한
const COLLAPSE_PER_FRAME = 2;
const CELL = 20;
const COLS = W / CELL;
const ROWS = H / CELL;
const NT = TILE_EDGES.length;
const ALL = (1 << NT) - 1;
const SEED0 = 66110;
const TXT_RUN = 'Picking tiles that match neighbors';
const TXT_RESTARTS = 'Restarts';
// 방향 d(0 위·1 오른쪽·2 아래·3 왼쪽)의 이웃 칸 오프셋과 맞은편 변
const DC = [0, 1, 0, -1];
const DR = [-1, 0, 1, 0];
function edgeOf(t, d) { return TILE_EDGES[t].charCodeAt(d) - 48; }
// 미리 계산 — FIT[d][e] 는 "방향 d 의 이웃으로 놓였을 때 맞은편 변이 e 인 타일" 의 비트 묶음
const FIT = [];
for (let d = 0; d < 4; d += 1) {
const row = [0, 0, 0];
for (let t = 0; t < NT; t += 1) {
row[edgeOf(t, (d + 2) % 4)] |= 1 << t;
}
FIT.push(row);
}
function bits(m) {
let c = 0;
let x = m;
while (x) {
x &= x - 1;
c += 1;
}
return c;
}
const cand = new Int32Array(COLS * ROWS);
const fixedTile = new Int8Array(COLS * ROWS);
const flash = new Float64Array(COLS * ROWS);
let tie = new Float64Array(COLS * ROWS);
let trng = makeRng(SEED0);
let left = 0;
let restarts = 0;
let lastContradiction = -1;
function resetGrid() {
cand.fill(ALL);
fixedTile.fill(-1);
flash.fill(0);
left = COLS * ROWS;
clearArt(INK);
}
function plant(s) {
trng = makeRng(s);
tie = new Float64Array(COLS * ROWS);
for (let i = 0; i < tie.length; i += 1) {
tie[i] = trng() * 0.1;
}
restarts = 0;
lastContradiction = -1;
resetGrid();
}
function drawTile(i) {
const t = fixedTile[i];
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
const mx = x0 + CELL / 2;
const my = y0 + CELL / 2;
actx.fillStyle = rgba(mix(PAL[8], INK, 0.18), 1);
actx.fillRect(x0, y0, CELL, CELL);
// 물길 먼저, 그 위에 길
for (let kind = 2; kind >= 1; kind -= 1) {
const half = kind === 2 ? 4.5 : 2.6;
actx.fillStyle = kind === 2 ? rgba(PAL[6], 1) : rgba(mix(PAL[4], PAPER, 0.5), 1);
let any = false;
for (let d = 0; d < 4; d += 1) {
if (edgeOf(t, d) !== kind) {
continue;
}
any = true;
if (d === 0) {
actx.fillRect(mx - half, y0, half * 2, CELL / 2 + half);
}
else if (d === 1) {
actx.fillRect(mx - half, my - half, CELL / 2 + half, half * 2);
}
else if (d === 2) {
actx.fillRect(mx - half, my - half, half * 2, CELL / 2 + half);
}
else {
actx.fillRect(x0, my - half, CELL / 2 + half, half * 2);
}
}
if (any) {
// 가운데 이음 — 꺾이는 자리도 끊기지 않게
actx.beginPath();
actx.arc(mx, my, half, 0, TAU);
actx.fill();
}
}
}
function fix(i, t) {
fixedTile[i] = t;
cand[i] = 1 << t;
flash[i] = 1;
left -= 1;
drawTile(i);
}
// 제약 전파 — 바뀐 칸을 큐에 넣고, 이웃마다 "맞닿은 변이 맞는 후보" 만 남긴다. 후보가 0 이 되면 모순(false)
function propagate(start) {
const queue = [start];
let q = 0;
while (q < queue.length) {
const i = queue[q];
q += 1;
const c = i % COLS;
const r = Math.floor(i / COLS);
for (let d = 0; d < 4; d += 1) {
const nc = c + DC[d];
const nr = r + DR[d];
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) {
continue;
}
const j = nr * COLS + nc;
if (fixedTile[j] >= 0) {
continue;
}
// 이 칸 후보들이 방향 d 쪽에 내놓는 변 종류 — 그 종류와 맞는 타일만 이웃에 남는다
let allow = 0;
for (let t = 0; t < NT; t += 1) {
if (cand[i] & (1 << t)) {
allow |= FIT[d][edgeOf(t, d)];
}
}
const nm = cand[j] & allow;
if (nm === cand[j]) {
continue;
}
if (nm === 0) {
return false;
}
cand[j] = nm;
if (bits(nm) === 1) {
fix(j, 31 - Math.clz32(nm));
}
queue.push(j);
}
}
return true;
}
// 후보가 가장 적은 칸(같으면 Seed 난수로 정한 순서) 하나를 가중치 난수로 고른다
function collapseOne() {
let best = -1;
let bestScore = 1e9;
for (let i = 0; i < cand.length; i += 1) {
if (fixedTile[i] >= 0) {
continue;
}
const s = bits(cand[i]) + tie[i];
if (s < bestScore) {
bestScore = s;
best = i;
}
}
if (best < 0) {
return true;
}
let total = 0;
for (let t = 0; t < NT; t += 1) {
if (cand[best] & (1 << t)) {
total += TILE_WEIGHTS[t];
}
}
let pick = trng() * total;
let chosen = 31 - Math.clz32(cand[best]);
for (let t = 0; t < NT; t += 1) {
if (!(cand[best] & (1 << t))) {
continue;
}
pick -= TILE_WEIGHTS[t];
if (pick <= 0) {
chosen = t;
break;
}
}
fix(best, chosen);
if (!propagate(best)) {
// 모순 — 어떤 칸에도 놓을 타일이 없다. 횟수를 세고 판 전체를 처음부터(난수는 이어서)
restarts += 1;
lastContradiction = best;
resetGrid();
}
return left === 0;
}
function grow() {
for (let k = 0; k < cand.length; k += 1) {
flash[k] = Math.max(0, flash[k] - 0.08);
}
for (let k = 0; k < COLLAPSE_PER_FRAME; k += 1) {
if (collapseOne()) {
return true;
}
}
return left === 0;
}
function paintFrame() {
blitArt();
// 아직 정해지지 않은 칸 — 남은 후보가 적을수록 안개가 옅어진다(전파가 퍼지는 모습)
for (let i = 0; i < cand.length; i += 1) {
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
if (fixedTile[i] < 0) {
const k = bits(cand[i]) / NT;
ctx.fillStyle = rgba(mix(PAL[3], INK, 0.55), 0.25 + 0.6 * k);
ctx.fillRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
}
else if (flash[i] > 0) {
ctx.strokeStyle = rgba(PAPER, flash[i]);
ctx.lineWidth = 1.5;
ctx.strokeRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
}
}
}
function statPairs() {
return [['Cells left', String(left)], [TXT_RESTARTS, String(restarts)]];
}
function runText() { return restarts > 0 ? TXT_RUN + ' · ' + TXT_RESTARTS + ' ' + restarts : TXT_RUN; }
function doneText() { return TXT_DONE + ' · ' + TXT_RESTARTS + ' ' + restarts; }
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);
}
};
})();// TileNeighborMatch - vanilla 판(06-gen-art-tile-neighbor-match-collapse) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TileNeighborMatch.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TileNeighborMatch() {
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); }
}
// 타일마다 네 변(위·오른쪽·아래·왼쪽)의 종류 — 0 풀밭, 1 길, 2 물길. 맞닿는 두 변의 숫자가 같아야 이웃이 된다.
// 길과 물길이 엇갈리는 타일(다리)은 없다 — 그래서 길과 물길이 한 칸에서 만나야 하는 자리가 생기면 모순이 난다
const TILE_EDGES: string[] = [
'0000',
'0101', '1010',
'1100', '0110', '0011', '1001',
'1110', '0111', '1011', '1101',
'1111',
'0202', '2020',
'2200', '0220', '0022', '2002',
];
// 고를 때의 가중치 — 같은 순서. 풀밭이 가장 넓게
const TILE_WEIGHTS: number[] = [
6,
2, 2,
1, 1, 1, 1,
0.6, 0.6, 0.6, 0.6,
0.4,
1.6, 1.6,
0.8, 0.8, 0.8, 0.8,
];
// 한 틱에 고르는(무너뜨리는) 칸 수 — 성장 속도이자 프레임당 계산량 상한
const COLLAPSE_PER_FRAME = 2;
const CELL = 20;
const COLS = W / CELL;
const ROWS = H / CELL;
const NT = TILE_EDGES.length;
const ALL = (1 << NT) - 1;
const SEED0 = 66110;
const TXT_RUN = '이웃과 맞는 타일을 고르는 중';
const TXT_RESTARTS = '모순 재시작';
// 방향 d(0 위·1 오른쪽·2 아래·3 왼쪽)의 이웃 칸 오프셋과 맞은편 변
const DC = [0, 1, 0, -1];
const DR = [-1, 0, 1, 0];
function edgeOf(t: number, d: number): number { return TILE_EDGES[t].charCodeAt(d) - 48; }
// 미리 계산 — FIT[d][e] 는 "방향 d 의 이웃으로 놓였을 때 맞은편 변이 e 인 타일" 의 비트 묶음
const FIT: number[][] = [];
for (let d = 0; d < 4; d += 1) {
const row: number[] = [0, 0, 0];
for (let t = 0; t < NT; t += 1) { row[edgeOf(t, (d + 2) % 4)] |= 1 << t; }
FIT.push(row);
}
function bits(m: number): number {
let c = 0;
let x = m;
while (x) { x &= x - 1; c += 1; }
return c;
}
const cand = new Int32Array(COLS * ROWS);
const fixedTile = new Int8Array(COLS * ROWS);
const flash = new Float64Array(COLS * ROWS);
let tie = new Float64Array(COLS * ROWS);
let trng: () => number = makeRng(SEED0);
let left = 0;
let restarts = 0;
let lastContradiction = -1;
function resetGrid(): void {
cand.fill(ALL);
fixedTile.fill(-1);
flash.fill(0);
left = COLS * ROWS;
clearArt(INK);
}
function plant(s: number): void {
trng = makeRng(s);
tie = new Float64Array(COLS * ROWS);
for (let i = 0; i < tie.length; i += 1) { tie[i] = trng() * 0.1; }
restarts = 0;
lastContradiction = -1;
resetGrid();
}
function drawTile(i: number): void {
const t = fixedTile[i];
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
const mx = x0 + CELL / 2;
const my = y0 + CELL / 2;
actx.fillStyle = rgba(mix(PAL[8], INK, 0.18), 1);
actx.fillRect(x0, y0, CELL, CELL);
// 물길 먼저, 그 위에 길
for (let kind = 2; kind >= 1; kind -= 1) {
const half = kind === 2 ? 4.5 : 2.6;
actx.fillStyle = kind === 2 ? rgba(PAL[6], 1) : rgba(mix(PAL[4], PAPER, 0.5), 1);
let any = false;
for (let d = 0; d < 4; d += 1) {
if (edgeOf(t, d) !== kind) { continue; }
any = true;
if (d === 0) { actx.fillRect(mx - half, y0, half * 2, CELL / 2 + half); }
else if (d === 1) { actx.fillRect(mx - half, my - half, CELL / 2 + half, half * 2); }
else if (d === 2) { actx.fillRect(mx - half, my - half, half * 2, CELL / 2 + half); }
else { actx.fillRect(x0, my - half, CELL / 2 + half, half * 2); }
}
if (any) {
// 가운데 이음 — 꺾이는 자리도 끊기지 않게
actx.beginPath();
actx.arc(mx, my, half, 0, TAU);
actx.fill();
}
}
}
function fix(i: number, t: number): void {
fixedTile[i] = t;
cand[i] = 1 << t;
flash[i] = 1;
left -= 1;
drawTile(i);
}
// 제약 전파 — 바뀐 칸을 큐에 넣고, 이웃마다 "맞닿은 변이 맞는 후보" 만 남긴다. 후보가 0 이 되면 모순(false)
function propagate(start: number): boolean {
const queue: number[] = [start];
let q = 0;
while (q < queue.length) {
const i = queue[q];
q += 1;
const c = i % COLS;
const r = Math.floor(i / COLS);
for (let d = 0; d < 4; d += 1) {
const nc = c + DC[d];
const nr = r + DR[d];
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) { continue; }
const j = nr * COLS + nc;
if (fixedTile[j] >= 0) { continue; }
// 이 칸 후보들이 방향 d 쪽에 내놓는 변 종류 — 그 종류와 맞는 타일만 이웃에 남는다
let allow = 0;
for (let t = 0; t < NT; t += 1) { if (cand[i] & (1 << t)) { allow |= FIT[d][edgeOf(t, d)]; } }
const nm = cand[j] & allow;
if (nm === cand[j]) { continue; }
if (nm === 0) { return false; }
cand[j] = nm;
if (bits(nm) === 1) { fix(j, 31 - Math.clz32(nm)); }
queue.push(j);
}
}
return true;
}
// 후보가 가장 적은 칸(같으면 씨앗 난수로 정한 순서) 하나를 가중치 난수로 고른다
function collapseOne(): boolean {
let best = -1;
let bestScore = 1e9;
for (let i = 0; i < cand.length; i += 1) {
if (fixedTile[i] >= 0) { continue; }
const s = bits(cand[i]) + tie[i];
if (s < bestScore) { bestScore = s; best = i; }
}
if (best < 0) { return true; }
let total = 0;
for (let t = 0; t < NT; t += 1) { if (cand[best] & (1 << t)) { total += TILE_WEIGHTS[t]; } }
let pick = trng() * total;
let chosen = 31 - Math.clz32(cand[best]);
for (let t = 0; t < NT; t += 1) {
if (!(cand[best] & (1 << t))) { continue; }
pick -= TILE_WEIGHTS[t];
if (pick <= 0) { chosen = t; break; }
}
fix(best, chosen);
if (!propagate(best)) {
// 모순 — 어떤 칸에도 놓을 타일이 없다. 횟수를 세고 판 전체를 처음부터(난수는 이어서)
restarts += 1;
lastContradiction = best;
resetGrid();
}
return left === 0;
}
function grow(): boolean {
for (let k = 0; k < cand.length; k += 1) { flash[k] = Math.max(0, flash[k] - 0.08); }
for (let k = 0; k < COLLAPSE_PER_FRAME; k += 1) {
if (collapseOne()) { return true; }
}
return left === 0;
}
function paintFrame(): void {
blitArt();
// 아직 정해지지 않은 칸 — 남은 후보가 적을수록 안개가 옅어진다(전파가 퍼지는 모습)
for (let i = 0; i < cand.length; i += 1) {
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
if (fixedTile[i] < 0) {
const k = bits(cand[i]) / NT;
ctx.fillStyle = rgba(mix(PAL[3], INK, 0.55), 0.25 + 0.6 * k);
ctx.fillRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
} else if (flash[i] > 0) {
ctx.strokeStyle = rgba(PAPER, flash[i]);
ctx.lineWidth = 1.5;
ctx.strokeRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
}
}
}
function statPairs(): [string, string][] {
return [['남은 칸', String(left)], [TXT_RESTARTS, String(restarts)]];
}
function runText(): string { return restarts > 0 ? TXT_RUN + ' · ' + TXT_RESTARTS + ' ' + restarts : TXT_RUN; }
function doneText(): string { return TXT_DONE + ' · ' + TXT_RESTARTS + ' ' + restarts; }
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>
);
}06 · Tiles That Fill In by Keeping Only Pieces That Match Their Neighbors — Measured area 50.553% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
const j = nr * COLS + nc;
if (fixedTile[j] >= 0) {
continue;
}
// Edge types this cell's candidates offer toward direction d — only neighbor tiles matching those types remain
let allow = 0;
for (let t = 0; t < NT; t += 1) {
if (cand[i] & (1 << t)) {
allow |= FIT[d][edgeOf(t, d)];
}
}
const nm = cand[j] & allow;
if (nm === cand[j]) {
continue;
}
if (nm === 0) {
return false;
}
cand[j] = nm;
if (bits(nm) === 1) {
fix(j, 31 - Math.clz32(nm));
}
queue.push(j);
// Pick the cell with the fewest candidates (ties broken by a seeded order) and choose its tile by weighted random
function collapseOne() {
let best = -1;
let bestScore = 1e9;
for (let i = 0; i < cand.length; i += 1) {
if (fixedTile[i] >= 0) {
continue;
}
const s = bits(cand[i]) + tie[i];
if (s < bestScore) {
bestScore = s;
best = i;
}
}
if (best < 0) {
return true;
}
07Tree Rings That Close Without a Seam After a Full Turn
Rings of a different width each year stack out to a 138px radius, each ring reads its angle as a point on a circle in noise space so the radius wobbles within ±8%, and that circle moves by 0.07 per ring so neighboring rings resemble each other. Wide years are painted light and narrow years as dark, thick bands, and each new ring swells into place over 5 ticks, a match for wood or furniture brands and company history pages. Seed 47107 drew all 24 rings in 120 ticks with a 0px radius gap between angle 0 and 2π, and its intensity of 138.6 was the highest of the nine, with 35.8% cumulative area and 18/23 frames.

<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="Noise-warped tree rings are added outward one layer at a time, each year with a different width. 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">Adding rings one by one</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 makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 평균 고리 폭(px) — 해마다 이 값의 0.45~1.55 배로 넓었다 좁았다 한다
const RING_SPACING = 5.5;
// 노이즈가 반지름을 일그러뜨리는 비율 — 0.08 이면 반지름의 ±8%
const RING_NOISE_AMP = 0.08;
// 고리 하나를 이루는 점 수
const RING_SEGMENTS = 160;
// 고리 하나가 자라는 틱 수 — 성장 속도
const RING_TICKS = 5;
// 줄기 반지름(px)과 가운데
const TRUNK_R = 138;
const TRUNK_CX = 300;
const TRUNK_CY = 150;
// 노이즈 공간에 그리는 원의 반지름 — 각도를 이 Circles 위의 좌표로 바꿔 읽으므로 0 과 2π 가 같은 자리다
const NOISE_LOOP_R = 1.3;
// 고리마다 노이즈 원의 가운데를 이만큼 옮긴다 — 이웃 고리는 닮고, 멀리 떨어진 고리는 달라진다
const RING_DRIFT = 0.07;
const SEED0 = 47107;
const TXT_RUN = 'Adding rings one by one';
let wnoise = makeNoise(makeRng(SEED0));
let radii = [];
let widths = [];
let pts = [];
let grown = 0;
let ringTick = 0;
function plant(s) {
clearArt(INK);
const rng = makeRng(s);
wnoise = makeNoise(rng);
radii = [];
widths = [];
pts = [];
grown = 0;
ringTick = 0;
// 누적 반지름 — 해마다 다른 폭을 더해 간다
let r = 4;
while (r < TRUNK_R) {
const w = RING_SPACING * (0.45 + rng() * 1.1);
r = Math.min(TRUNK_R, r + w);
radii.push(r);
widths.push(w);
}
}
// k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 Circles 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
function ringR(k, a, base) {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k, base) {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
function grow() {
if (grown >= radii.length) {
return true;
}
ringTick += 1;
if (ringTick >= RING_TICKS) {
// 다 자란 고리는 점을 한 번만 계산해 둔다
pts.push(ringPoints(grown, radii[grown]));
grown += 1;
ringTick = 0;
}
return grown >= radii.length;
}
function ringPath(p) {
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let j = 1; j < RING_SEGMENTS; j += 1) {
ctx.lineTo(p[j * 2], p[j * 2 + 1]);
}
ctx.closePath();
}
const EARLY = mix(PAL[4], PAPER, 0.35);
const LATE = mix(PAL[4], INK, 0.42);
function paintFrame() {
blitArt();
const rings = pts.slice();
// 지금 자라는 고리 — 앞 고리 반지름에서 제 반지름까지 부드럽게 부푼다
if (grown < radii.length) {
const from = grown > 0 ? radii[grown - 1] : 0;
const k = ringTick / RING_TICKS;
rings.push(ringPoints(grown, lerp(from, radii[grown], k * k * (3 - 2 * k))));
}
// 바깥 고리부터 칠한다 — 안쪽 고리가 위에 덮여 고리 사이 띠만 남는다. 넓은 해는 밝게, 좁은 해는 진하게
for (let k = rings.length - 1; k >= 0; k -= 1) {
ringPath(rings[k]);
const wide = clamp((widths[k] / RING_SPACING - 0.45) / 1.1, 0, 1);
ctx.fillStyle = rgba(mix(LATE, EARLY, 0.35 + 0.65 * wide), 1);
ctx.fill();
ctx.strokeStyle = rgba(LATE, 0.95);
ctx.lineWidth = 0.8 + (1 - wide) * 1.4;
ctx.stroke();
}
if (grown >= radii.length && pts.length) {
ringPath(pts[pts.length - 1]);
ctx.strokeStyle = rgba(mix(LATE, INK, 0.5), 1);
ctx.lineWidth = 6;
ctx.stroke();
}
}
function statPairs() {
return [['Rings', grown + ' / ' + radii.length]];
}
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);
}
};
})();// TreeRingNoiseGrowth - vanilla 판(07-gen-art-tree-ring-noise-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TreeRingNoiseGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TreeRingNoiseGrowth() {
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); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 평균 고리 폭(px) — 해마다 이 값의 0.45~1.55 배로 넓었다 좁았다 한다
const RING_SPACING = 5.5;
// 노이즈가 반지름을 일그러뜨리는 비율 — 0.08 이면 반지름의 ±8%
const RING_NOISE_AMP = 0.08;
// 고리 하나를 이루는 점 수
const RING_SEGMENTS = 160;
// 고리 하나가 자라는 틱 수 — 성장 속도
const RING_TICKS = 5;
// 줄기 반지름(px)과 가운데
const TRUNK_R = 138;
const TRUNK_CX = 300;
const TRUNK_CY = 150;
// 노이즈 공간에 그리는 원의 반지름 — 각도를 이 원 위의 좌표로 바꿔 읽으므로 0 과 2π 가 같은 자리다
const NOISE_LOOP_R = 1.3;
// 고리마다 노이즈 원의 가운데를 이만큼 옮긴다 — 이웃 고리는 닮고, 멀리 떨어진 고리는 달라진다
const RING_DRIFT = 0.07;
const SEED0 = 47107;
const TXT_RUN = '나이테가 한 겹씩 느는 중';
let wnoise: Noise = makeNoise(makeRng(SEED0));
let radii: number[] = [];
let widths: number[] = [];
let pts: Float64Array[] = [];
let grown = 0;
let ringTick = 0;
function plant(s: number): void {
clearArt(INK);
const rng = makeRng(s);
wnoise = makeNoise(rng);
radii = [];
widths = [];
pts = [];
grown = 0;
ringTick = 0;
// 누적 반지름 — 해마다 다른 폭을 더해 간다
let r = 4;
while (r < TRUNK_R) {
const w = RING_SPACING * (0.45 + rng() * 1.1);
r = Math.min(TRUNK_R, r + w);
radii.push(r);
widths.push(w);
}
}
// k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 원 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
function ringR(k: number, a: number, base: number): number {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k: number, base: number): Float64Array {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
function grow(): boolean {
if (grown >= radii.length) { return true; }
ringTick += 1;
if (ringTick >= RING_TICKS) {
// 다 자란 고리는 점을 한 번만 계산해 둔다
pts.push(ringPoints(grown, radii[grown]));
grown += 1;
ringTick = 0;
}
return grown >= radii.length;
}
function ringPath(p: Float64Array): void {
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let j = 1; j < RING_SEGMENTS; j += 1) { ctx.lineTo(p[j * 2], p[j * 2 + 1]); }
ctx.closePath();
}
const EARLY: RGB = mix(PAL[4], PAPER, 0.35);
const LATE: RGB = mix(PAL[4], INK, 0.42);
function paintFrame(): void {
blitArt();
const rings: Float64Array[] = pts.slice();
// 지금 자라는 고리 — 앞 고리 반지름에서 제 반지름까지 부드럽게 부푼다
if (grown < radii.length) {
const from = grown > 0 ? radii[grown - 1] : 0;
const k = ringTick / RING_TICKS;
rings.push(ringPoints(grown, lerp(from, radii[grown], k * k * (3 - 2 * k))));
}
// 바깥 고리부터 칠한다 — 안쪽 고리가 위에 덮여 고리 사이 띠만 남는다. 넓은 해는 밝게, 좁은 해는 진하게
for (let k = rings.length - 1; k >= 0; k -= 1) {
ringPath(rings[k]);
const wide = clamp((widths[k] / RING_SPACING - 0.45) / 1.1, 0, 1);
ctx.fillStyle = rgba(mix(LATE, EARLY, 0.35 + 0.65 * wide), 1);
ctx.fill();
ctx.strokeStyle = rgba(LATE, 0.95);
ctx.lineWidth = 0.8 + (1 - wide) * 1.4;
ctx.stroke();
}
if (grown >= radii.length && pts.length) {
ringPath(pts[pts.length - 1]);
ctx.strokeStyle = rgba(mix(LATE, INK, 0.5), 1);
ctx.lineWidth = 6;
ctx.stroke();
}
}
function statPairs(): [string, string][] {
return [['나이테', grown + ' / ' + radii.length]];
}
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>
);
}07 · Tree Rings That Add One Wavy Layer at a Time — Measured area 35.837% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Radius of ring k at angle a — reads the angle as a point on a circle in noise space, so a full turn closes on the same value
function ringR(k, a, base) {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k, base) {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
08Lichen Colonies Claiming One Edge Cell at a Time
Seven colonies are planted on a board of 9,000 cells, each 4px square, and in turn each one pulls a cell from its edge list with the seeded generator and claims it if it is still empty, swapping the pulled slot with the last entry before removing it so a long list does not slow down in step with its length. Early cells are painted dark and late ones light, and a dark 1px line marks every edge shared with another colony, which suits a dashboard backdrop that splits regions or team areas. Seed 47108 filled all 9,000 cells in 150 ticks and left 732 borders, and at 75.5% cumulative area it was the widest of the nine, with intensity 112.3 and 21/23 frames.

<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="Each lichen colony spreads by filling one random empty cell along its edge, and colors fade by birth order. 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">Lichen colonies spreading</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);
}
}
// 군락 수 — Seed 칸 하나씩에서 출발한다
const COLONY_COUNT = 7;
// 한 틱에 새로 채우는 칸 수(군락을 차례로 돌며 하나씩) — 성장 속도이자 프레임당 계산량 상한
const CELLS_PER_FRAME = 60;
// 태어난 순서로 옅어지는 정도 — 마지막 칸은 짙은 색에서 밝은 색 쪽으로 이 비율만큼 간다
const AGE_FADE = 0.6;
const CELL = 4;
const COLS = W / CELL;
const ROWS = H / CELL;
const TOTAL = COLS * ROWS;
const SEED0 = 47108;
const TXT_RUN = 'Lichen colonies spreading';
const owner = new Int8Array(TOTAL);
// 칸마다 그 칸을 가장자리 목록에 넣은 군락들의 비트 — 같은 칸을 한 군락 목록에 두 번 넣지 않게
const inFront = new Int32Array(TOTAL);
let fronts = [];
let lrng = makeRng(SEED0);
let born = 0;
let turn = 0;
let fresh = [];
let borders = 0;
function colonyDeep(c) { return mix(PAL[c % PAL.length], INK, 0.35); }
function colonyLight(c) { return mix(PAL[c % PAL.length], PAPER, 0.55); }
function claim(i, c) {
owner[i] = c;
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
// 태어난 순서로 색을 옅게 — 먼저 난 가운데는 짙고 늦게 난 가장자리는 밝다
actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
actx.fillRect(x0, y0, CELL, CELL);
born += 1;
fresh.push(i);
const col = i % COLS;
const row = Math.floor(i / COLS);
const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
for (let k = 0; k < 4; k += 1) {
const j = nb[k];
if (j < 0) {
continue;
}
if (owner[j] < 0) {
if (!(inFront[j] & (1 << c))) {
inFront[j] |= 1 << c;
fronts[c].push(j);
}
}
else if (owner[j] !== c) {
// 다른 군락과 맞닿은 변 — 짙은 선으로 경계를 긋는다
borders += 1;
actx.fillStyle = rgba(INK, 0.7);
if (k === 0) {
actx.fillRect(x0, y0, 1, CELL);
}
else if (k === 1) {
actx.fillRect(x0 + CELL - 1, y0, 1, CELL);
}
else if (k === 2) {
actx.fillRect(x0, y0, CELL, 1);
}
else {
actx.fillRect(x0, y0 + CELL - 1, CELL, 1);
}
}
}
}
function plant(s) {
clearArt(INK);
lrng = makeRng(s);
owner.fill(-1);
inFront.fill(0);
fronts = [];
born = 0;
turn = 0;
fresh = [];
borders = 0;
for (let c = 0; c < COLONY_COUNT; c += 1) {
fronts.push([]);
let i = Math.floor(lrng() * TOTAL);
while (owner[i] >= 0) {
i = (i + 1) % TOTAL;
}
claim(i, c);
}
}
function grow() {
fresh = [];
let made = 0;
let idle = 0;
while (made < CELLS_PER_FRAME && idle < COLONY_COUNT) {
const c = turn;
turn = (turn + 1) % COLONY_COUNT;
const f = fronts[c];
if (!f.length) {
idle += 1;
continue;
}
idle = 0;
// 가장자리 목록에서 하나를 무작위로 꺼낸다(맨 끝 칸과 자리를 바꿔 빼서 목록을 당기지 않는다)
const k = Math.floor(lrng() * f.length);
const i = f[k];
f[k] = f[f.length - 1];
f.pop();
inFront[i] &= ~(1 << c);
if (owner[i] < 0) {
claim(i, c);
made += 1;
}
}
return idle >= COLONY_COUNT;
}
function paintFrame() {
blitArt();
// 이번 틱에 붙은 칸 — 밝게 반짝
ctx.fillStyle = rgba(PAPER, 0.85);
for (let k = 0; k < fresh.length; k += 1) {
const i = fresh[k];
ctx.fillRect((i % COLS) * CELL + 0.5, Math.floor(i / COLS) * CELL + 0.5, CELL - 1, CELL - 1);
}
}
function statPairs() {
return [['Cells filled', born + ' / ' + TOTAL], ['Borders', String(borders)]];
}
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);
}
};
})();// LichenEdgeAccretion - vanilla 판(08-gen-art-lichen-edge-accretion) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LichenEdgeAccretion.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LichenEdgeAccretion() {
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); }
}
// 군락 수 — 씨앗 칸 하나씩에서 출발한다
const COLONY_COUNT = 7;
// 한 틱에 새로 채우는 칸 수(군락을 차례로 돌며 하나씩) — 성장 속도이자 프레임당 계산량 상한
const CELLS_PER_FRAME = 60;
// 태어난 순서로 옅어지는 정도 — 마지막 칸은 짙은 색에서 밝은 색 쪽으로 이 비율만큼 간다
const AGE_FADE = 0.6;
const CELL = 4;
const COLS = W / CELL;
const ROWS = H / CELL;
const TOTAL = COLS * ROWS;
const SEED0 = 47108;
const TXT_RUN = '이끼 군락이 번지는 중';
const owner = new Int8Array(TOTAL);
// 칸마다 그 칸을 가장자리 목록에 넣은 군락들의 비트 — 같은 칸을 한 군락 목록에 두 번 넣지 않게
const inFront = new Int32Array(TOTAL);
let fronts: number[][] = [];
let lrng: () => number = makeRng(SEED0);
let born = 0;
let turn = 0;
let fresh: number[] = [];
let borders = 0;
function colonyDeep(c: number): RGB { return mix(PAL[c % PAL.length], INK, 0.35); }
function colonyLight(c: number): RGB { return mix(PAL[c % PAL.length], PAPER, 0.55); }
function claim(i: number, c: number): void {
owner[i] = c;
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
// 태어난 순서로 색을 옅게 — 먼저 난 가운데는 짙고 늦게 난 가장자리는 밝다
actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
actx.fillRect(x0, y0, CELL, CELL);
born += 1;
fresh.push(i);
const col = i % COLS;
const row = Math.floor(i / COLS);
const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
for (let k = 0; k < 4; k += 1) {
const j = nb[k];
if (j < 0) { continue; }
if (owner[j] < 0) {
if (!(inFront[j] & (1 << c))) { inFront[j] |= 1 << c; fronts[c].push(j); }
} else if (owner[j] !== c) {
// 다른 군락과 맞닿은 변 — 짙은 선으로 경계를 긋는다
borders += 1;
actx.fillStyle = rgba(INK, 0.7);
if (k === 0) { actx.fillRect(x0, y0, 1, CELL); }
else if (k === 1) { actx.fillRect(x0 + CELL - 1, y0, 1, CELL); }
else if (k === 2) { actx.fillRect(x0, y0, CELL, 1); }
else { actx.fillRect(x0, y0 + CELL - 1, CELL, 1); }
}
}
}
function plant(s: number): void {
clearArt(INK);
lrng = makeRng(s);
owner.fill(-1);
inFront.fill(0);
fronts = [];
born = 0;
turn = 0;
fresh = [];
borders = 0;
for (let c = 0; c < COLONY_COUNT; c += 1) {
fronts.push([]);
let i = Math.floor(lrng() * TOTAL);
while (owner[i] >= 0) { i = (i + 1) % TOTAL; }
claim(i, c);
}
}
function grow(): boolean {
fresh = [];
let made = 0;
let idle = 0;
while (made < CELLS_PER_FRAME && idle < COLONY_COUNT) {
const c = turn;
turn = (turn + 1) % COLONY_COUNT;
const f = fronts[c];
if (!f.length) { idle += 1; continue; }
idle = 0;
// 가장자리 목록에서 하나를 무작위로 꺼낸다(맨 끝 칸과 자리를 바꿔 빼서 목록을 당기지 않는다)
const k = Math.floor(lrng() * f.length);
const i = f[k];
f[k] = f[f.length - 1];
f.pop();
inFront[i] &= ~(1 << c);
if (owner[i] < 0) { claim(i, c); made += 1; }
}
return idle >= COLONY_COUNT;
}
function paintFrame(): void {
blitArt();
// 이번 틱에 붙은 칸 — 밝게 반짝
ctx.fillStyle = rgba(PAPER, 0.85);
for (let k = 0; k < fresh.length; k += 1) {
const i = fresh[k];
ctx.fillRect((i % COLS) * CELL + 0.5, Math.floor(i / COLS) * CELL + 0.5, CELL - 1, CELL - 1);
}
}
function statPairs(): [string, string][] {
return [['채운 칸', born + ' / ' + TOTAL], ['경계', String(borders)]];
}
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>
);
}08 · Lichen Colonies Spreading One Edge Cell at a Time — Measured area 75.494% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
function claim(i, c) {
owner[i] = c;
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
// Fade color by birth order — the early center is dark, the late edge is light
actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
actx.fillRect(x0, y0, CELL, CELL);
born += 1;
fresh.push(i);
const col = i % COLS;
const row = Math.floor(i / COLS);
const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
for (let k = 0; k < 4; k += 1) {
const j = nb[k];
if (j < 0) {
continue;
}
if (owner[j] < 0) {
if (!(inFront[j] & (1 << c))) {
inFront[j] |= 1 << c;
fronts[c].push(j);
}
}
09A Hero That Grows Flow Lines, Veins and Circles From One Seed
One seed is mixed with a lane number so that flow lines, veins, circles and noise each draw from their own random stream, and the layers start growing in turn at 0ms, 480ms and 960ms, with circles keeping 2px clear of vein nodes. Once all three finish, the scene holds for 1,400ms, a copy of the scene alone is set aside, and that copy fades out over the next seed's scene across 700ms, so the board drops straight into a landing hero. One demo cycle (47109 → 76513) took 909 ticks and 14,544ms, and the capture recorded 39.0% cumulative area, intensity 73.4 and 23/23 frames.

<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="A hero backdrop where flow lines, veins and packed circles grow in turn from one seed, hold when finished, then fade into the next seed's picture. 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">Three layers growing in turn</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 makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
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;
}
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng, noise, o) {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) Length 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f, x, y) {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 Length 안)은 세지 않는다
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f, x, y) {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f, x, y) {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f, x, y) {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// Lines 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f) {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f, budget, seg) {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) {
flowStart(f, cx, cy);
}
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) {
f.fails = 0;
flowStart(f, rx, ry);
}
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) {
f.done = true;
}
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 Lines 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
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();
}
function makePack(rng, o) {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 Circles(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
function packStep(p, onStop) {
if (p.done) {
return true;
}
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
}
else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
}
// 첫 그림의 Seed — 다시 그리기를 누르면 이 수만 바뀌고 세 겹이 모두 함께 바뀐다
const HERO_SEED = 47109;
// 겹과 겹 사이 출발 간격(ms) — 흐름 Lines 0, 잎맥 1배, Circles 채우기 2배 뒤에 자라기 시작한다
const LAYER_STAGGER_MS = 480;
// 세 겹이 다 자란 뒤 머무는 시간(ms) — 그 뒤 다음 씨앗의 그림으로 옅게 바뀐다
const HOLD_MS = 1400;
// 앞 그림이 사라지는 교차 페이드 시간(ms)
const FADE_MS = 700;
// 겹마다 한 틱의 일 — 프레임당 계산량 상한
const HERO_FLOW_STEPS = 44;
const SEED0 = HERO_SEED;
const TXT_RUN = 'Three layers growing in turn';
// 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x, y) {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s) {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function buildVein(s) {
return makeVein(laneRng(s, 1), {
count: 420, influence: 70, kill: 7, seg: 4, rootX: VEIN_CX, rootY: H + 2, maxNodes: 3000, maxIter: 300,
x0: VEIN_CX - 150, y0: VEIN_CY - 100, x1: VEIN_CX + 150, y1: VEIN_CY + 100, inside: insideCrown,
});
}
let fl = buildFlow(SEED0);
let vn = buildVein(SEED0);
// 원은 잎맥 마디를 피해 자란다 — 마디가 Circles 안(r + 2px)에 있으면 그 자리는 막혔다
function blockedByVein(x, y, r) {
const lim = r + 2;
return bucketNear(vn.grid, x, y, lim, function (id) {
const dx = vn.nx[id] - x;
const dy = vn.ny[id] - y;
return dx * dx + dy * dy < lim * lim;
});
}
function buildPack(s) {
return makePack(laneRng(s, 2), {
grow: 0.6, attempts: 50, gap: 3, maxR: 20, startR: 1, perTick: 6, failTicks: 18,
x0: 8, y0: 8, x1: W - 8, y1: H - 8, blocked: blockedByVein,
});
}
let pk = buildPack(SEED0);
let holdAt = -1;
let fadeLeft = 0;
const prevEl = document.createElement('canvas');
const pctx = prevEl.getContext('2d');
function plantLayers(s) {
clearArt(INK);
fl = buildFlow(s);
vn = buildVein(s);
pk = buildPack(s);
holdAt = -1;
}
function plant(s) {
plantLayers(s);
fadeLeft = 0;
}
function flowSeg(ax, ay, bx, by) {
actx.strokeStyle = rgba(mix(PAL[3], PAL[0], clamp((bx - ax) / 2.5 * 0.5 + 0.5, 0, 1)), 0.5);
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function drawScene(c) {
c.drawImage(artEl, 0, 0, W, H);
veinDraw(vn, c, mix(PAL[2], PAPER, 0.3), 0.95, 0.5, 0.17, 4.4);
for (let i = 0; i < pk.cx.length; i += 1) {
c.fillStyle = rgba(PAL[pk.col[i]], 0.88);
c.beginPath();
c.arc(pk.cx[i], pk.cy[i], pk.cr[i], 0, TAU);
c.fill();
}
}
// 앞 그림을 따로 떠 둔다 — 새 씨앗이 자라는 동안 그 위에 옅어지며 겹친다
function snapshot() {
if (!pctx) {
return;
}
prevEl.width = cv.width;
prevEl.height = cv.height;
pctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
drawScene(pctx);
}
function grow() {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) {
flowStep(fl, HERO_FLOW_STEPS, flowSeg);
}
if (age >= LAYER_STAGGER_MS && !vn.done) {
veinStep(vn);
}
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
packStep(pk, function () { });
}
const all = fl.done && vn.done && pk.done;
if (!all) {
return false;
}
// 동작 줄이기에서는 다 자란 한 장에서 멈춘다
if (still) {
return true;
}
if (holdAt < 0) {
holdAt = age;
}
if (age - holdAt >= HOLD_MS) {
snapshot();
seed = nextSeed(seed);
plantLayers(seed);
age = 0;
fadeLeft = FADE_MS;
}
return false;
}
function paintFrame() {
drawScene(ctx);
if (fadeLeft > 0) {
ctx.globalAlpha = fadeLeft / FADE_MS;
ctx.drawImage(prevEl, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
function statPairs() {
return [['Lines', String(fl.lines)], ['Nodes', String(vn.nx.length)], ['Circles', String(pk.cx.length)]];
}
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);
}
};
})();// HeroSeededGrowth - vanilla 판(09-gen-art-hero-seeded-growth-scene) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HeroSeededGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroSeededGrowth() {
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); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 격자 버킷 — 점 번호를 칸(한 변 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 FlowOpts = { sep: number; test: number; step: number; scale: number; cell: number; maxPts: number; margin: number };
type FlowSeg = (ax: number, ay: number, bx: number, by: number, line: number) => void;
type Flow = {
o: FlowOpts; rng: () => number; vx: Float64Array; vy: Float64Array; fcols: number; frows: number;
px: number[]; py: number[]; pl: number[]; po: number[]; grid: Buckets;
cand: number[]; candAt: number; line: number; dir: number; ord: number; x: number; y: number; sx: number; sy: number; pts: number[];
active: boolean; lines: number; length: number; fails: number; done: boolean;
};
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng: () => number, noise: Noise, o: FlowOpts): Flow {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 길이 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f: Flow, x: number, y: number): [number, number] {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
function flowTooClose(f: Flow, x: number, y: number, d: number, line: number, ord: number): boolean {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id: number): boolean {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) { return false; }
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f: Flow, x: number, y: number): boolean {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f: Flow, x: number, y: number): void {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f: Flow, x: number, y: number): void {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 선 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f: Flow): void {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f: Flow, budget: number, seg: FlowSeg): boolean {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) { flowStart(f, cx, cy); }
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) { f.fails = 0; flowStart(f, rx, ry); }
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) { f.done = true; }
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
} else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 끌개 점 잎맥 — 끌개마다 가장 가까운 마디를 찾고, 마디는 자기를 고른 끌개 방향들의 평균으로 한 칸 자란다. 마디가 닿은 끌개는 지운다
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();
}
// 부푸는 원 채우기 — 빈자리를 몇 번 골라 원을 심고 틱마다 반지름을 키우다가, 이웃 원·테두리와의 틈이 gap 에 닿으면 그 원만 멈춘다
type PackOpts = {
grow: number; attempts: number; gap: number; maxR: number; startR: number; perTick: number; failTicks: number;
x0: number; y0: number; x1: number; y1: number; blocked: (x: number, y: number, r: number) => boolean;
};
type Pack = {
o: PackOpts; rng: () => number; cx: number[]; cy: number[]; cr: number[]; col: number[];
growing: number[]; grid: Buckets; fails: number; tries: number; rejects: number; done: boolean;
};
function makePack(rng: () => number, o: PackOpts): Pack {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 원(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p: Pack, x: number, y: number, r: number, self: number): boolean {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) { return false; }
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j: number): boolean {
if (j === self) { return false; }
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) { return false; }
return !p.o.blocked(x, y, r + g);
}
function packStep(p: Pack, onStop: (i: number) => void): boolean {
if (p.done) { return true; }
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
} else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next: number[] = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
} else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) { p.done = true; }
return p.done;
}
// 첫 그림의 씨앗 — 다시 그리기를 누르면 이 수만 바뀌고 세 겹이 모두 함께 바뀐다
const HERO_SEED = 47109;
// 겹과 겹 사이 출발 간격(ms) — 흐름 선 0, 잎맥 1배, 원 채우기 2배 뒤에 자라기 시작한다
const LAYER_STAGGER_MS = 480;
// 세 겹이 다 자란 뒤 머무는 시간(ms) — 그 뒤 다음 씨앗의 그림으로 옅게 바뀐다
const HOLD_MS = 1400;
// 앞 그림이 사라지는 교차 페이드 시간(ms)
const FADE_MS = 700;
// 겹마다 한 틱의 일 — 프레임당 계산량 상한
const HERO_FLOW_STEPS = 44;
const SEED0 = HERO_SEED;
const TXT_RUN = '세 겹이 차례로 자라는 중';
// 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
function laneRng(base: number, lane: number): () => number { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x: number, y: number): boolean {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s: number): Flow {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function buildVein(s: number): Vein {
return makeVein(laneRng(s, 1), {
count: 420, influence: 70, kill: 7, seg: 4, rootX: VEIN_CX, rootY: H + 2, maxNodes: 3000, maxIter: 300,
x0: VEIN_CX - 150, y0: VEIN_CY - 100, x1: VEIN_CX + 150, y1: VEIN_CY + 100, inside: insideCrown,
});
}
let fl: Flow = buildFlow(SEED0);
let vn: Vein = buildVein(SEED0);
// 원은 잎맥 마디를 피해 자란다 — 마디가 원 안(r + 2px)에 있으면 그 자리는 막혔다
function blockedByVein(x: number, y: number, r: number): boolean {
const lim = r + 2;
return bucketNear(vn.grid, x, y, lim, function (id: number): boolean {
const dx = vn.nx[id] - x;
const dy = vn.ny[id] - y;
return dx * dx + dy * dy < lim * lim;
});
}
function buildPack(s: number): Pack {
return makePack(laneRng(s, 2), {
grow: 0.6, attempts: 50, gap: 3, maxR: 20, startR: 1, perTick: 6, failTicks: 18,
x0: 8, y0: 8, x1: W - 8, y1: H - 8, blocked: blockedByVein,
});
}
let pk: Pack = buildPack(SEED0);
let holdAt = -1;
let fadeLeft = 0;
const prevEl = document.createElement('canvas');
const pctx = prevEl.getContext('2d');
function plantLayers(s: number): void {
clearArt(INK);
fl = buildFlow(s);
vn = buildVein(s);
pk = buildPack(s);
holdAt = -1;
}
function plant(s: number): void {
plantLayers(s);
fadeLeft = 0;
}
function flowSeg(ax: number, ay: number, bx: number, by: number): void {
actx.strokeStyle = rgba(mix(PAL[3], PAL[0], clamp((bx - ax) / 2.5 * 0.5 + 0.5, 0, 1)), 0.5);
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function drawScene(c: CanvasRenderingContext2D): void {
c.drawImage(artEl, 0, 0, W, H);
veinDraw(vn, c, mix(PAL[2], PAPER, 0.3), 0.95, 0.5, 0.17, 4.4);
for (let i = 0; i < pk.cx.length; i += 1) {
c.fillStyle = rgba(PAL[pk.col[i]], 0.88);
c.beginPath();
c.arc(pk.cx[i], pk.cy[i], pk.cr[i], 0, TAU);
c.fill();
}
}
// 앞 그림을 따로 떠 둔다 — 새 씨앗이 자라는 동안 그 위에 옅어지며 겹친다
function snapshot(): void {
if (!pctx) { return; }
prevEl.width = cv.width;
prevEl.height = cv.height;
pctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
drawScene(pctx);
}
function grow(): boolean {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) { flowStep(fl, HERO_FLOW_STEPS, flowSeg); }
if (age >= LAYER_STAGGER_MS && !vn.done) { veinStep(vn); }
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) { packStep(pk, function (): void { /* 멈춘 원도 프레임마다 다시 그린다 */ }); }
const all = fl.done && vn.done && pk.done;
if (!all) { return false; }
// 동작 줄이기에서는 다 자란 한 장에서 멈춘다
if (still) { return true; }
if (holdAt < 0) { holdAt = age; }
if (age - holdAt >= HOLD_MS) {
snapshot();
seed = nextSeed(seed);
plantLayers(seed);
age = 0;
fadeLeft = FADE_MS;
}
return false;
}
function paintFrame(): void {
drawScene(ctx);
if (fadeLeft > 0) {
ctx.globalAlpha = fadeLeft / FADE_MS;
ctx.drawImage(prevEl, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
function statPairs(): [string, string][] {
return [['선', String(fl.lines)], ['마디', String(vn.nx.length)], ['원', String(pk.cx.length)]];
}
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>
);
}09 · A Hero Backdrop Where Three Layers Grow From One Seed — Measured area 39.016% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// A different random stream per lane from the same seed — the three layers do not share numbers, so their patterns stay independent
function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x, y) {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s) {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function grow() {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) {
flowStep(fl, HERO_FLOW_STEPS, flowSeg);
}
if (age >= LAYER_STAGGER_MS && !vn.done) {
veinStep(vn);
}
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
packStep(pk, function () { });
}
const all = fl.done && vn.done && pk.done;
if (!all) {
return false;
}
Tens of Thousands of Points, New Seeds That Look the Same, a Resized Screen — Where Growing Patterns Break
Tick counts and totals in this section come from loading the functions of each vanilla index.html into Node and running them again, while figures for a first build were measured the same way on an earlier version during development. Millisecond timings were taken in Node on our test machine and will differ from device to device, so tick counts are the fairer comparison.
Neighbor checks eat the budget as points multiply
01 piles up 8,386 points, 04 around 1,500 circles and 03 as many as 2,600 nodes. Without the grid, 04 would compare (60 spot draws plus the growing circles) against all 1,500 circles every tick, and the screen would likely stutter as the count climbs. 03 has work per tick that rises with its node count, and at the 2,600-node cap the heaviest tick measured in Node took 2.0ms. In 08, pulling a cell out of the middle of the edge list with splice would shift everything behind it, so the cell trades places with the last one and leaves from the end.
A flow line that blocks itself on its first step
If 01 counted the points just behind it on the same line, its own previous point would sit within 4px from the very first step and the line would halt at once. That is why flowTooClose skips points on the same line within 8 steps before and after, a stretch twice the spacing long. At the center of a swirl a line could circle forever, so it is cut off at 700 points (MAX_LINE_POINTS).
Veins pinned between attractors pulling in opposite directions
The first build of 02 normalized even an average direction close to zero and kept growing from it, and seeds 47102 and 27672 then failed to clear their last 2 attractors, ran all the way to MAX_ITER 400 and swelled to 1,102 nodes. Two attractors on opposite sides likely tugged one node back and forth in place. Resting any node whose average is shorter than 0.2 for that tick, and stopping after 40 ticks in a row with no attractor removed, brought the finish down to 103 ticks. The 6px kill distance sits above the 2.6px node length, likely so a new node cannot hop past an attractor and swing back and forth around it.
A folding line that does not lengthen on pushing and splitting alone
The first version of 03 had nothing but long-edge splitting and repulsion, and after 8,000 ticks it still sat at 47 nodes. The push between neighbors points along the line, so those forces likely canceled out. The line began to lengthen only after a spring holding neighbors at 4px and growth nodes inserted into seeded edges were added. Inserting with splice shifts the index of every later node by one, so i jumps one extra place right after each insert.
A late finish check means waiting on a nearly identical picture
04 uses FAIL_TICKS to decide how many ticks in a row with no new circle end the run. With 24 the run took 1,070 ticks, and with 8 it ended at 565. The spots left open in between take circles of only 1 to 2px, so the picture barely differs. When two circles grow in the same tick, the one checked later tests against the already enlarged radius of the first, so they do not overlap.
A crack with nowhere to start
The first build of 05 looked for a new crack's starting point with 1,200 tries at random pixels, and because the opening board held only 12 marked pixels, not one try hit a marked pixel and the run ended with 0 cracks. It now picks straight from marks, the list of pixels that already carry an angle. The first 4 steps after a start are a grace period so a new crack does not stop instantly on its parent's pixels. Angle differences are not wrapped at 360°, so −90° and 270° count as different cracks even though they point the same way, a limit that remains in the source.
Bridge tiles would keep the board from collapsing
06 has no bridge tile where a road crosses a stream. An earlier board with 2 bridge tiles had 0 contradictions across 200 seeds, while the current board without them collapses once in 7 of 400 seeds. That suggests a place where a road and a stream can meet leaves no dead-end cell, and demo seed 66110 was picked from those seven so the refill is visible. With no backtracking, the whole board starts over, so a bigger board also means a costlier restart. Picking among equal candidate counts in index order would fill rows from the top left, so every cell adds a seeded tie-breaker, tie.
The 7.40px step from reading the angle as a straight line
Change ringR in 07 so it feeds the angle straight in as the noise x, and at the same amplitude the radius at 0 and at 2π differs by 7.40px, putting a step in every ring. Reading the angle as a point on a circle brings the end of the turn back to the same noise position as the start, so the gap is 0px. The amplitude scales with the radius and reaches ±11px on the outer ring at 138px, so where a year is only 2.67px wide, neighboring rings could cross. Each ring's noise circle moves by just 0.07 from the last, which likely keeps that crossing almost invisible in the actual picture.
Why a new seed can look much like the old one
A new seed moves things around but keeps the character. In 02, seeds 21080 and 27672 also cleared every attractor by tick 103 (85 and 103 branch tips), in 04, seeds 36918 and 94117 rejected 95.2% and 94.2% of spots, less than one percentage point from the 94.9% of 47104, and 08 fills 9,000 cells at 60 per tick, so any seed finishes in 150 ticks (732, 821 and 975 borders). To change the impression, the constants in the variant table below move it faster than a new seed.
Three layers disturbing each other in the hero
If 09 shared one random stream across its three layers, changing one flow line setting would likely reshuffle the vein and circle patterns drawn after it, so laneRng gives each layer its own lane. Circles start at 960ms and avoid only the vein nodes grown by then, so veins that keep growing afterward can still overlap a circle, a gap left in the source. Copying the previous scene straight off the visible canvas would carry the stats box in the lower right into the fade, so snapshot redraws the scene alone with drawScene.
When screen size or pixel ratio changes
Coordinates inside the canvas stay at 480×300, and only the outer box resizes through width: min(92vw, calc(160vh - 12.8vw)) and aspect-ratio: 480 / 300. A phone therefore grows the same picture from the same seed. The real pixel count is multiplied by the device ratio but capped at 2 with Math.min(window.devicePixelRatio || 1, 2). Resizing a canvas wipes what it holds, so when the ratio changes, fitCanvas parks the artwork on a temporary canvas and copies it back at the new size.
If you would like to grow the nine vanilla folders with your own seed and your own colors, type 9rhueet8 when the downloaded zip asks for its password, and React versions running the same math are in the same archive.
What Changes When You Shift Line Spacing, Attractor Radius and Repel Radius?
| Variant | Value | Feel |
|---|---|---|
| Widen the line spacing | 01 LINE_SEPARATION 8 → 16 |
On seed 47101 the line count drops from 155 to 79 and total length from 16,462px to 8,372px (0.51 times), and growth that took 135 ticks ends in 66. Doubling the gap leaves room for only as many lines as the area divided by the spacing, so length roughly halves and the grain turns loose and airy. |
| Widen the attractor radius | 02 INFLUENCE_RADIUS 60 → 120 |
Nodes go from 794 to 815 and branch tips from 71 to 81, while growth still takes 103 ticks, so the picture is close to unchanged. Total growth time measured in Node rose from 24.6ms to 40.0ms. With 900 attractors packed into the leaf, the nearest node is likely already found within 60px. |
| Increase the repel radius | 03 REPEL_RADIUS 12 → 22 |
At the same 120 ticks, mean thickness (twice the area divided by the perimeter) rises from 7.5px to 13.0px, width from 171px to 271px and area from 12,036px² to 29,297px². Tight folds become broad, loose loops. |
Of the three rows, 01 and 03 visibly change the grain of the picture, while the attractor radius in 02 leaves the picture nearly as it was and mostly adds computation. 02 is a reminder to measure both whether the picture really changes and how much the cost grows before you enlarge a radius.
Patterns moved by CSS keyframes instead are in generative pattern backgrounds, and areas split by nearest point are in Voronoi diagram patterns. Crystals that grow by random walks sticking together are in snowflake crystal growth, trees grown by rewriting rule strings are in fractal tree on canvas, and scenes made from per-pixel math sit in 9 canvas scene effects.
One Finished Frame as a Still Image — Accessibility for Reduced Motion and Background Art
When the operating system asks for reduced motion (prefers-reduced-motion), none of the nine runs its requestAnimationFrame loop. finishNow() instead grows that seed to the end within a cap of 6,000 ticks (GROW_GUARD) and paints the finished picture at once, and switching or regrowing seeds with Enter, R or the buttons simply swaps in the next finished picture. 09 stays on one fully grown three-layer frame, with no crossfade and no next seed. A change listener on matchMedia watches the setting, so switching it with the page open takes effect right away.
The keyboard reaches all nine as well. Each canvas has tabindex="0" and an aria-label that describes the picture and its keys, and with focus inside, Enter draws a new seed, R regrows the same seed and Space pauses or resumes. Only those three keys have their default action blocked, so Tab and other keys pass through. Redraw, Same seed and Pause are real <button> elements, the pause button reports its state through aria-pressed, and both canvases and buttons show a :focus-visible outline.
The status line stays quiet with aria-live="off" while the demo runs on its own, switches to polite the first time a person interacts, and changes its text only when the phase changes, such as growing, fully grown or paused. In 06 the growing status adds a restart count only after at least one restart, while the fully grown message carries it even at zero. Counts that change every tick, such as lines, nodes or circles, are painted in the box at the lower right of the canvas rather than in the status line, so a screen reader is not fed a number on every frame. When the picture sits behind text purely as decoration, removing the buttons and key hints and hiding the canvas from screen readers is kinder to people listening.
Questions to Ask Before Laying Generative Art Behind Your Site
Can this generative pattern maker show a different picture on every visit?
The first seed is one line, SEED0 in 01 to 08 and HERO_SEED in 09. The sources leave out the clock and Math.random on purpose so that a seed keeps giving the same picture, so to vary it per visit, generate that number on the page side and pass it in, and keep the number if you ever want to see that picture again. A number fixed to each page, such as its post number, gives every page a different picture that still comes back unchanged on reload.
Where do I change the colors to match my brand?
In all nine sources the colors sit near the top of the script as the INK, PAPER and PAL constants, written as RGB arrays, and each of the three feeds the drawing calls. Changing only those array values recolors the lines, nodes and circles while the math and shapes stay as they are. The rounded corners (border-radius) and shadow of the stage box follow the tokens in style.scss.
Will it run without strain on a phone?
The work per tick has a ceiling. 01 is held to 70 steps, 04 to 8 new circles and 08 to 60 cells per tick by constants, a frame runs at most 6 ticks, and the pixel ratio is capped at 2. 03 at its 2,600-node cap had a heaviest tick of 2.0ms in Node, but that figure comes from our test machine, so on a slow device, lowering MAX_NODES or the per-tick caps is the safer move.









