01서로 간격을 지키며 흐름장을 따라 자라는 선
FLOW_NOISE_SCALE 로 읽은 값 노이즈가 칸마다 정한 방향을 따라 선을 STEP_LENGTH 걸음으로 앞뒤로 늘리고, 격자 칸에 담아 둔 다른 선의 점이 LINE_SEPARATION 의 SEP_TEST 배(절반)보다 가까워지면 그 자리에서 멈춘 뒤, 끝난 선 양옆 LINE_SEPARATION 자리에서 다음 선을 낸다.
쓰임포트폴리오 첫 화면 배경
FLOW_NOISE_SCALELINE_SEPARATIONSTEP_LENGTH

<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="노이즈로 정한 흐름을 따라 선이 앞뒤로 자라고, 다른 선과 정한 간격보다 가까워지면 멈추는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">흐름 선이 자라는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">다시 그리기</button>
<button class="pc__btn" data-role="replay" type="button">같은 씨앗</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</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) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
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;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(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, 일시정지 동안은 멈춘다)
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,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 길이 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 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
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;
}
// 선 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
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)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
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) {
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();
// 지금 자라는 선 끝
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 [['선', String(flow.lines)], ['길이', Math.round(flow.length) + 'px']];
}
function runText() { return TXT_RUN; }
function doneText() { 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) {
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 = [['씨앗', 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 서로 간격을 지키며 흐름장을 따라 자라는 선 — 측정 누적면적 28.044% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 제너레이티브 아트 9종 — 랜덤 패턴 만들기에서 자라는 그림까지
이 데모가 속한 글
제너레이티브 아트 9종 — 랜덤 패턴 만들기에서 자라는 그림까지씨앗 숫자 하나만 같으면 몇 번을 다시 그려도 같은 그림이 나온다. 02번은 흩어진 끌개 점을 향해 잎맥을 뻗고, 03번은 선이 늘어날 때마다 마디를 끼워 주름을 접으며, 06번은 이웃 칸과 변이 맞는 타일만 남긴다 이 글의 데모 전부 보기


