L시스템 프랙탈 나무 9종 — 문자 하나가 가지를 뻗는 캔버스
프랙탈 나무는 몇 글자의 문자열을 규칙대로 다시 쓰고 그 결과를 거북이가 걸어 그리는 L시스템 그림입니다. F 하나는 앞으로, + 하나는 회전입니다. 이 몇 글자를 몇 번 다시 읽기만 해도 나뭇가지가 저절로 뻗는데, 그 재귀 엔진을 통째로 복붙으로 가져갑니다.
01 lsys-tree-recursive-branch
02 lsys-pythagoras-tree
03 lsys-koch-snowflake
04 lsys-sierpinski-triangle
05 lsys-sierpinski-carpet
06 lsys-dragon-curve
07 lsys-plant-parametric
08 lsys-tree-wind-sway
09 lsys-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 재귀 가지 — 두 갈래로 갈라지는 최소 나무
- 02 피타고라스 나무 — 정사각형 위에 정사각형
- 03 코흐 눈송이 — 변을 들어올려 무한 해안선
- 04 셸핀스키 삼각형 — 숨은 삼각형의 삼각형
- 05 셸핀스키 카펫 — 칸을 비우며 파고드는 구멍
- 06 용 곡선 — 접기 규칙이 만드는 종이공룡
- 07 매개변수 식물 — F+-[] 문자열이 자라는 잎
- 08 바람에 흔들리는 나무 — 세대마다 위상이 어긋나는 진동
- 09 대문 숲 — 브랜드 자막 위로 자라는 프랙탈 숲
데모
아홉 장면 전부 파일 하나씩으로 열리자마자 세대 0 에서 최대 세대까지 자라기를 시작하고, 화면의 모든 변화는 CSS 키프레임이 아니라 다시 쓴 문자열을 거북이가 처음부터 순회한 결과를 다시 그린 것입니다. 24프레임 녹화에서 일곱 장면이 23프레임, 05 카펫과 07 식물이 22프레임 움직였는데 세대 경계에서 진행률이 한 프레임 쉬는 지점이 있는 것이지 멈춘 것이 아닙니다. 성장은 주기 2초 안에 차오르고, 다 자란 뒤에도 가지 끝의 호흡·바람·스포트라이트로 화면은 계속 살아 있습니다.
24프레임 실측 한눈에
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 재귀 가지 | F[+F][-F]F · 26° · 세대 0→6 | 2.123 | 82.9 |
| 02 피타고라스 나무 | 정사각형 0.707 배 축소 · 세대 0→8 | 1.792 | 104.6 |
| 03 코흐 눈송이 | F+F--F+F · 60° · 세대 0→4 | 8.315 | 45.0 |
| 04 셸핀스키 삼각형 | A→B-A-B · B→A+B+A · 60° | 5.87 | 83.7 |
| 05 셸핀스키 카펫 | 1→111101111 · getImageData 실측 | 16.167 | 141.3 |
| 06 용 곡선 | X+YF+ · -FX-Y · 90° · 세대 0→11 | 5.192 | 88.4 |
| 07 매개변수 식물 | 25° ± 6° 사인파 흔들림 | 6.449 | 89.2 |
| 08 바람 나무 | 깊이 제곱 사인파 · 진폭 30° | 1.706 | 70.2 |
| 09 대문 숲 | 규칙 3종 · 나무 5그루 동시 성장 | 11.5 | 63.6 |
수치는 구성의 성격으로 읽는다
누적면적이 대부분 1~8% 대에 머무는 것은 이 편의 성격입니다. 거북이 그리는 것이 두께 1~5px 안팎의 선분이라 화면을 스치는 정도일 수밖에 없고, 면적이 커지는 두 장면은 예외가 아니라 다른 방식입니다 — 05 카펫은 채운 칸을 fillRect 로 그려 남은 넓이가 그대로 픽셀 점유율이 되어 16.167%, 09 대문 숲은 나무 다섯 그루가 동시에 자라 겹쳐 11.5% 까지 오릅니다. 강도 평균은 05 카펫이 141.3 으로 가장 밝은데 채운 사각형과 배경의 대비가 크기 때문이고, 02 피타고라스 나무 104.6 이 계절 색 순환으로 뒤따릅니다. 면적최대는 05 가 한 프레임에 14.143% 까지 차오르고 09 는 9.198% 입니다.
F 하나의 재귀에서 곡선·매개변수까지 번진 L-system 순서
순서는 화려함의 서열이 아니라 문자 재쓰기가 어디까지 밀려나는가를 한 단계씩 넓히는 서열입니다. 먼저 문자 한 종류 F 하나를 규칙 하나로 다시 써 최소의 두 갈래 나무를 얻고(01), 같은 재귀를 정사각형 위에 올려 관 모양 숲을 만듭니다(02). 세 번째·네 번째는 재쓰기가 닫힌 도형에 닿습니다 — 변을 들어올리는 코흐 눈송이(03)와 두 문자가 서로를 다시 쓰는 셸핀스키 삼각형(04). 다섯째는 거북 해석을 벗어나 칸을 비우는 규칙으로 바뀌고 남은 넓이를 실측합니다(05). 여섯째부터 일곱 번째는 재쓰기가 곡선(06)과 매개변수(07)로 확장되고, 여덟 번째는 세대 번호가 진동 파라미터로 쓰입니다(08). 마지막에 규칙 세 가지를 나무 다섯 그루에 돌려써 대문 장면으로 조립합니다(09). 문자열을 벡터처럼 적분해 그림을 얻는다는 점에서 발산 횟수를 세어 색을 매기는 만델브로 줌과 같은 프랙탈 축의 이웃이고, 실 감기 한 올 한 올을 계산하는 스트링아트 캔버스와도 측정의 대상이 다를 뿐 규칙을 프레임마다 다시 계산한다는 계보입니다.
항목별 코드
01재귀 가지 — 두 갈래로 갈라지는 최소 나무
axiom F 하나를 규칙 F→F[+F][-F]F 로 세대마다 다시 쓰고 거북이가 그 문자열을 그대로 걸으면 두 갈래로 갈라지는 나무가 나오는, 프랙탈 나무의 모든 규칙이 시작되는 최소 구현입니다. 세대마다 길이는 0.74 배로 줄고 6세대까지 2초 주기 안 약 1.5초에 걸쳐 자라며, 유지 구간엔 가지 끝이 숨 호흡으로 진동합니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>F 하나는 앞으로, + 하나는 회전입니다. F → F[+F][-F]F 를 여섯 세대 다시 쓰는 것만으로 가지가 저절로 뻗는데, 자라는 속도를 한 세대씩 끊어 보면 규칙이 어디서 분기하는지 보입니다</span>
</p>
<div class="ls__field" role="application" aria-label="재귀 가지 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">규칙 F[+F][-F]F · 각도 26° · 세대 0 → 6</span>
<span class="ls__meta ls__meta--dim">canvas · 문자 재쓰기 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 재귀 가지(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자열 재쓰기 세대 성장 + 잎 호흡 밝기 진동)가 만든다.
// 무대는 숲 밤 이끼. 파스텔 팔레트를 --ls-* 로 편 공통(01~09) 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
// ── 편 공통 숲 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$ls-stage: #1a2116; // 숲 밤 이끼 — 저채도 짙은 녹색 무대 (파스텔 검사 무관: 저채도·저명도)
@include stage($ls-stage);
$ls-ink: #12160e; // 관측창 바닥 — 무대보다 한 톤 어두운 이끼검
$ls-bark: #d8c4a0; // 줄기 — 파스텔 베이지
$ls-bark2: #e6d8bc; // 어린 가지 — 밝은 크림베이지
$ls-leaf: #bfe3c8; // 잎 — 파스텔 민트
$ls-mote: #f2ead6; // 떠다니는 씨앗 점
$ls-mist: #dfe6d6; // 보조 글자 — 안개 톤
.ls {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --ls-* 를 받아 쓴다
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mote: #{$ls-mote};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 완성 나무 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mote: #f2ead6;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 재귀 가지(01) — L-시스템 최소 나무. 줄기 F 를 규칙 F[+F][-F]F 로 다시 쓰고
// 거북이(F=전진, +=반시계 회전, -=시계 회전, [=상태 저장, ]=복귀)로 선분을 적분한다.
// [ ] 스택이 없으면 두 번째 갈래가 첫 갈래 끝에서 시작해 나무가 아니라 지그재그가 된다.
// 움직임 = 세대 성장 애니메이션. 6세대까지 자라면(약 1.5초) 2초 루프 안에서 잎 호흡(가지 끝
// 밝기 진동)으로 23/23 프레임을 채운다. 계산 축: 세대마다 문자열을 다시 쓰고 좌표를 적분.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var ANGLE = 26; // 가지 각도(도) — 25도에 가까운 낮은 각이 침엽수 실루엣
var RATIO = 0.74; // 세대마다 줄기가 짧아지는 비율
var CYCLE = 2.0; // 성장+호흡 주기(초) — 설정 초(2s)와 같아 한 루프
// ────────────────────────────────────────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark'); // 줄기 — 베이지
var C_BARK2 = tokRGB('--ls-bark2'); // 어린 가지 — 밝은 색
var C_LEAF = tokRGB('--ls-leaf'); // 잎 — 파스텔 민트
var C_INK = tokRGB('--ls-ink'); // 관측창 바닥
var C_MOTE = tokRGB('--ls-mote'); // 떠다니는 씨앗 점
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// L-시스템 문자열 재쓰기 — axiom F 를 규칙으로 n세대 전개
var RULE = { 'F': 'F[+F][-F]F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 세대별 문자열을 미리 전개해 둔다 — 매 프레임 재계산하지 않는다(문자 길이 3^gen)
var GENS = [];
for (var g = 0; g <= 6; g++) GENS.push(expand('F', g));
// 거북 해석 — 문자열을 선분 목록 {x1,y1,x2,y2,depth} 로 적분
function turtle(str, x0, y0, len0, angDeg) {
var segs = [];
var x = x0, y = y0, a = -Math.PI / 2; // 위를 향해 시작(캔버스 y축 아래방향 보정)
var stackX = [], stackY = [], stackA = [];
var len = len0;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var nx = x + Math.cos(a) * len, ny = y + Math.sin(a) * len;
segs.push({ x1: x, y1: y, x2: nx, y2: ny });
x = nx; y = ny;
} else if (ch === '+') {
a -= angDeg * Math.PI / 180;
} else if (ch === '-') {
a += angDeg * Math.PI / 180;
} else if (ch === '[') {
stackX.push(x); stackY.push(y); stackA.push(a);
} else if (ch === ']') {
x = stackX.pop(); y = stackY.pop(); a = stackA.pop();
len *= 0; len += RATIO; // 닫힌 가지 뒤 길이 단계는 유지 — 아래에서 세대별로 다시 계산
}
}
return segs;
}
// 간단화: depth 는 스택 깊이로 세며 선분에 새기는 두 번째 구현이 아래 render 안에 있다.
// 떠다니는 씨앗 점 7개 — 성장 전 광합성 모티프(결정적 위상)
var MOTES = [];
for (var m = 0; m < 7; m++) {
MOTES.push({ px: 0.12 + 0.76 * ((m * 0.37) % 1), py: 0.2 + 0.5 * ((m * 0.61) % 1), ph: m * 0.9 });
}
function render(loop) {
var ctx = cv.getContext('2d');
// 성장 스케줄(2초 루프): 0~0.72 세대 0→6 성장, 0.72~1.0 완전 상태 유지+잎 호흡
var grPhase = Math.min(loop / 0.72, 1);
var genF = grPhase * 6; // 실수 세대 — 세대 사이를 선형으로 지나며 자란다
var gen = Math.min(6, Math.floor(genF));
var frac = genF - gen; // 현재 세대 안 진행률
var breath = 0.5 + 0.5 * Math.sin(loop / CYCLE * Math.PI * 6); // 잎 밝기 진동
// 바닥
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 떠다니는 씨앗 — 성장 초기(세대 0~2)에 잘 보이고 나중에도 느리게 떠 있는다
for (var mo = 0; mo < MOTES.length; mo++) {
var M = MOTES[mo];
var wx = M.px + 0.03 * Math.sin(loop / CYCLE * Math.PI * 2 + M.ph);
var wy = M.py + 0.05 * Math.cos(loop / CYCLE * Math.PI * 2 + M.ph * 1.3);
ctx.globalAlpha = 0.5 + 0.3 * Math.sin(loop / CYCLE * Math.PI * 4 + M.ph);
ctx.fillStyle = 'rgb(' + C_MOTE[0] + ',' + C_MOTE[1] + ',' + C_MOTE[2] + ')';
ctx.beginPath();
ctx.arc(wx * W, wy * H, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 뿌리 지면 — 얕은 실선
ctx.strokeStyle = 'rgba(' + C_BARK[0] + ',' + C_BARK[1] + ',' + C_BARK[2] + ',0.30)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.12, H * 0.92);
ctx.lineTo(W * 0.88, H * 0.92);
ctx.stroke();
// 성장 — 이전 세대(전체)를 밝게 깔고 현재 세대 마지막 분기를 이어 그린다
var baseLen = H * 0.24;
var x0 = W * 0.5, y0 = H * 0.92;
var strFull = GENS[gen];
if (gen > 0 && frac > 0) {
// 이전 세대를 완성형으로 깔고(어둡게), 현재 세대의 새 선분을 frac 비율로 이어 그린다.
drawTree(ctx, GENS[gen - 1], x0, y0, baseLen, 0.85, 0, gen - 1, 0);
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, frac, gen, breath);
} else {
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, gen === 0 ? Math.min(1, grPhase * 6) : 1, gen, breath);
}
}
// 거북 렌더러 — drawFrac: 마지막 세대의 새 선분은 frac 길이로.
// depth 는 스택 깊이 — 세대 번호를 그대로 색·굵기 감쇠 파라미터로 쓴다(077 기준 계산 축).
function drawTree(ctx, str, x0, y0, len0, dim, drawFrac, gen, breath) {
var x = x0, y = y0, a = -Math.PI / 2;
var stackX = [], stackY = [], stackA = [], stackL = [];
var depth = 0;
var maxDepth = Math.max(1, gen);
// 규칙 F[+F][-F]F 에서 한 세대에 F 세 개가 새로 생긴다 — 문자 인덱스로 새 선분 판별 대신
// 스택 깊이(=depth) 로 세대를 근사: depth 가 gen-1 이면 마지막 세대의 새 가지다.
var isNew = false;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var segLen = len0 * Math.pow(RATIO, depth);
var f = (depth >= gen && gen > 0) || (gen === 0) ? drawFrac : 1;
// depth >= gen 인 선분 = 이번 세대에 새로 생긴 선분 → 부분 길이로 성장
var nx = x + Math.cos(a) * segLen * f;
var ny = y + Math.sin(a) * segLen * f;
// 색: 깊을수록 어린 가지 — 베이지 → 밝은색, 끝은 잎색
var t = Math.min(1, depth / maxDepth);
var cr = C_BARK[0] + (C_BARK2[0] - C_BARK[0]) * t;
var cg = C_BARK[1] + (C_BARK2[1] - C_BARK[1]) * t;
var cb = C_BARK[2] + (C_BARK2[2] - C_BARK[2]) * t;
var isTip = (depth >= maxDepth);
if (isTip) {
var bl = 0.55 + 0.45 * breath; // 잎 호흡 — 끝 노드 밝기 진동
cr = C_BARK2[0] + (C_LEAF[0] - C_BARK2[0]) * bl;
cg = C_BARK2[1] + (C_LEAF[1] - C_BARK2[1]) * bl;
cb = C_BARK2[2] + (C_LEAF[2] - C_BARK2[2]) * bl;
}
ctx.globalAlpha = dim;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(1, 5 * Math.pow(0.62, depth));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
if (f >= 1) { x = nx; y = ny; } // 부분 성장 중에는 끝점을 옮기지 않는다
// 새 가지 끝에 잎 점
if (isTip && f >= 1) {
ctx.fillStyle = 'rgb(' + C_LEAF[0] + ',' + C_LEAF[1] + ',' + C_LEAF[2] + ')';
ctx.globalAlpha = dim * (0.16 + 0.18 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = dim * (0.4 + 0.45 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 2.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
} else if (ch === '+') {
a -= ANGLE * Math.PI / 180;
} else if (ch === '-') {
a += ANGLE * Math.PI / 180;
} else if (ch === '[') {
stackX.push(x); stackY.push(y); stackA.push(a); stackL.push(depth);
depth++;
} else if (ch === ']') {
x = stackX.pop(); y = stackY.pop(); a = stackA.pop(); depth = stackL.pop();
}
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE; // 0~1 루프 위상
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.6 : 0.8);
});
fit();
if (reduce) {
render(0.6); // reduce — 완성 나무 한 장만 고정
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysTreeRecursiveBranch — vanilla 판(run/328/demo/01-lsys-tree-recursive-branch/index.html) 의 React 포트.
// 줄기 F 를 규칙 F[+F][-F]F 로 세대마다 다시 쓰고 거북이(F=전진, +/-=회전, [/]=스택)로 선분을 적분한다.
// [ ] 스택이 없으면 두 번째 갈래가 첫 갈래 끝에서 시작해 나무가 아니라 지그재그가 된다.
// 움직임 = 세대 성장 애니메이션(0→6세대, 약 1.5초) + 완성 뒤 잎 호흡(가지 끝 밝기 진동).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysTreeRecursiveBranch.module.scss";
export type LsysTreeRecursiveBranchProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number; // 가지 각도(도) — 25도에 가까운 낮은 각이 침엽수 실루엣
ratio?: number; // 세대마다 줄기가 짧아지는 비율
cycle?: number; // 성장+호흡 주기(초) — 설정 초(2s)와 같아 한 루프
};
const ANGLE = 26;
const RATIO = 0.74;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const HINT_TEXT = "F 하나는 앞으로, + 하나는 회전입니다. F → F[+F][-F]F 를 여섯 세대 다시 쓰는 것만으로 가지가 저절로 뻗는데, 자라는 속도를 한 세대씩 끊어 보면 규칙이 어디서 분기하는지 보입니다";
const META_LEFT = "규칙 F[+F][-F]F · 각도 26° · 세대 0 → 6";
const META_RIGHT = "canvas · 문자 재쓰기 · requestAnimationFrame";
const FIELD_LABEL = "재귀 가지 실험판";
const RULE: Record<string, string> = { F: "F[+F][-F]F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
const GENS: string[] = [];
for (let g = 0; g <= 6; g++) GENS.push(expand("F", g));
// 떠다니는 씨앗 점 7개 — 성장 전 광합성 모티프(결정적 위상)
type Mote = { px: number; py: number; ph: number };
const MOTES: Mote[] = [];
for (let m = 0; m < 7; m++) {
MOTES.push({ px: 0.12 + 0.76 * ((m * 0.37) % 1), py: 0.2 + 0.5 * ((m * 0.61) % 1), ph: m * 0.9 });
}
export default function LsysTreeRecursiveBranch({
duration = "2s",
easing = "ease-out",
color = "#d8c4a0",
angle = ANGLE,
ratio = RATIO,
cycle = CYCLE
}: LsysTreeRecursiveBranchProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ bark: RGB; bark2: RGB; leaf: RGB; ink: RGB; mote: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, str: string, x0: number, y0: number, len0: number, dim: number, drawFrac: number, gen: number, breath: number): void {
const col = colRef.current;
if (!col) return;
let x = x0, y = y0, a = -Math.PI / 2;
const stackX: number[] = [], stackY: number[] = [], stackA: number[] = [], stackL: number[] = [];
let depth = 0;
const maxDepth = Math.max(1, gen);
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const segLen = len0 * Math.pow(ratio, depth);
const f = (depth >= gen && gen > 0) || gen === 0 ? drawFrac : 1;
const nx = x + Math.cos(a) * segLen * f;
const ny = y + Math.sin(a) * segLen * f;
const t = Math.min(1, depth / maxDepth);
let cr = col.bark[0] + (col.bark2[0] - col.bark[0]) * t;
let cg = col.bark[1] + (col.bark2[1] - col.bark[1]) * t;
let cb = col.bark[2] + (col.bark2[2] - col.bark[2]) * t;
const isTip = depth >= maxDepth;
if (isTip) {
const bl = 0.55 + 0.45 * breath;
cr = col.bark2[0] + (col.leaf[0] - col.bark2[0]) * bl;
cg = col.bark2[1] + (col.leaf[1] - col.bark2[1]) * bl;
cb = col.bark2[2] + (col.leaf[2] - col.bark2[2]) * bl;
}
ctx.globalAlpha = dim;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(1, 5 * Math.pow(0.62, depth));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
if (f >= 1) { x = nx; y = ny; }
if (isTip && f >= 1) {
ctx.fillStyle = "rgb(" + col.leaf[0] + "," + col.leaf[1] + "," + col.leaf[2] + ")";
ctx.globalAlpha = dim * (0.16 + 0.18 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = dim * (0.4 + 0.45 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 2.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
} else if (ch === "+") {
a -= angle * Math.PI / 180;
} else if (ch === "-") {
a += angle * Math.PI / 180;
} else if (ch === "[") {
stackX.push(x); stackY.push(y); stackA.push(a); stackL.push(depth);
depth++;
} else if (ch === "]") {
x = stackX.pop() as number; y = stackY.pop() as number; a = stackA.pop() as number; depth = stackL.pop() as number;
}
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
const grPhase = Math.min(loop / 0.72, 1);
const genF = grPhase * 6;
const gen = Math.min(6, Math.floor(genF));
const frac = genF - gen;
const breath = 0.5 + 0.5 * Math.sin(loop / cycle * Math.PI * 6);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
for (let mo = 0; mo < MOTES.length; mo++) {
const M = MOTES[mo];
const wx = M.px + 0.03 * Math.sin(loop / cycle * Math.PI * 2 + M.ph);
const wy = M.py + 0.05 * Math.cos(loop / cycle * Math.PI * 2 + M.ph * 1.3);
ctx.globalAlpha = 0.5 + 0.3 * Math.sin(loop / cycle * Math.PI * 4 + M.ph);
ctx.fillStyle = "rgb(" + col.mote[0] + "," + col.mote[1] + "," + col.mote[2] + ")";
ctx.beginPath();
ctx.arc(wx * W, wy * H, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.strokeStyle = "rgba(" + col.bark[0] + "," + col.bark[1] + "," + col.bark[2] + ",0.30)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.12, H * 0.92);
ctx.lineTo(W * 0.88, H * 0.92);
ctx.stroke();
const baseLen = H * 0.24;
const x0 = W * 0.5, y0 = H * 0.92;
const strFull = GENS[gen];
if (gen > 0 && frac > 0) {
drawTree(ctx, GENS[gen - 1], x0, y0, baseLen, 0.85, 0, gen - 1, 0);
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, frac, gen, breath);
} else {
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, gen === 0 ? Math.min(1, grPhase * 6) : 1, gen, breath);
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
bark2: tokRGB(root, "--ls-bark2"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink"),
mote: tokRGB(root, "--ls-mote")
};
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(reduce ? 0.8 : 0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.8);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, ratio, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}01 · 재귀 가지 — 두 갈래로 갈라지는 최소 나무 — 측정 누적면적 2.123% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
규칙 전개와 세대 사전 계산
// L-시스템 문자열 재쓰기 — axiom F 를 규칙으로 n세대 전개
var RULE = { 'F': 'F[+F][-F]F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 세대별 문자열을 미리 전개해 둔다 — 매 프레임 재계산하지 않는다(문자 길이 3^gen)
var GENS = [];
for (var g = 0; g <= 6; g++) GENS.push(expand('F', g));
거북 해석 — 문자열을 선분 목록으로
// 거북 해석 — 문자열을 선분 목록 {x1,y1,x2,y2,depth} 로 적분
function turtle(str, x0, y0, len0, angDeg) {
var segs = [];
var x = x0, y = y0, a = -Math.PI / 2; // 위를 향해 시작(캔버스 y축 아래방향 보정)
var stackX = [], stackY = [], stackA = [];
var len = len0;
for (var i = 0; i < str.length; i++) {
각도 26° 는 25도에 가까운 낮은 각이라 침엽수 실루엣이 나오는 값입니다.
02피타고라스 나무 — 정사각형 위에 정사각형
정사각형 한 변 위에 빗변을 밑변으로 삼는 정사각형 둘을 얹는 재귀를 세대 0→8 로 반복하면 관 모양 숲이 자라는, 실측 제안에도 있는 클래식 프랙탈입니다. 한 변은 매 세대 0.707(√2/2) 배로 줄어 8세대면 사각형이 약 250개까지 늘어나고, 다 자란 뒤엔 초록 여름에서 호박 가을을 지나 다시 봄으로 계절 색이 순환합니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>정사각형 위에 빗변을 밑변으로 삼는 정사각형 둘을 얹는 규칙을 0 → 1 → 2 세대로 되짚으면 관 모양 숲이 자라는데, 세대가 늘수록 면은 줄고 변은 늘어나 프랙탈 차원이 2 에 다가갑니다</span>
</p>
<div class="ls__field" role="application" aria-label="피타고라스 나무 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">한 변 s · 축소 0.707 · 세대 0 → 8</span>
<span class="ls__meta ls__meta--dim">canvas · fillRect · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 피타고라스 나무(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(재귀 정사각형 세대 성장 + 계절 색 순환)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01 과 동일 팔레트, --ls-leaf2 만 이 편부터 추가).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
// ── 편 공통 숲 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8; // 여름 잎 — 파스텔 민트
$ls-leaf2: #eddfb4; // 가을 잎 — 파스텔 앰버
$ls-mote: #f2ead6;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-leaf2: #{$ls-leaf2};
--ls-mote: #{$ls-mote};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-leaf2: #eddfb4;
--ls-mote: #f2ead6;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 피타고라스 나무(02) — 정사각형 위에 직각삼각형(빗변을 아래 변으로)을 얹고
// 그 빗변에 다시 정사각형 둘을 붙이는 재귀. L-시스템 문자열로도 쓸 수 있지만(0 → 1[1]1)
// 여기선 재귀 함수가 문자열 전개와 같은 나무를 만든다는 대응을 보여준다.
// 움직임 = 세대 성장(0→8) + 자란 뒤 계절 색 순환(초록 여름 → 호박 가을 → 다시 봄).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var SIDE0 = 0.21; // 첫 정사각형 한 변(무대 세로 배율) — 피사체를 키워 누적면적을 확보
var CYCLE = 2.0; // 성장+계절 주기(초)
var MAXGEN = 8; // 최대 세대 — 8 이면 사각형 약 250개(2^8 근사, 캔버스 서브픽셀 컬링됨)
// ────────────────────────────────────────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_TRUNK = tokRGB('--ls-bark'); // 몸통 — 베이지
var C_LEAFA = tokRGB('--ls-leaf'); // 여름 잎 — 파스텔 민트
var C_LEAFB = tokRGB('--ls-leaf2'); // 가을 잎 — 파스텔 앰버
var C_INK = tokRGB('--ls-ink');
var C_LINE = tokRGB('--ls-bark2');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 계절 보간 — t: 0(민트 여름) → 1(앰버 가을). 프랙탈 캔버스 색 혼합
function mix(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgb(c) { return 'rgb(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ')'; }
function render(loop) {
var ctx = cv.getContext('2d');
// 성장 스케줄: 0~0.55 세대 0→8 성장, 0.55~1.0 완전 숲 + 계절 색 순환
var grPhase = Math.min(loop / 0.55, 1);
var genF = grPhase * MAXGEN;
var seasonT = 0.5 - 0.5 * Math.cos((loop - 0.55) / 0.45 * Math.PI * 2); // 가을→봄→가을 왕복
if (loop < 0.55) seasonT = 0;
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var s0 = H * SIDE0;
var cx = W * 0.5, cy = H * 0.94;
// 완성 뒤 아주 약한 바람 흔들림 — 밑동 고정, 숲 전체 가장자리 픽셀을 계속 갱신해
// 계절 색 전환 구간이 아닐 때도 화면이 죽지 않게 한다(08 의 큰 진폭 흔들림과는 다른 미세 진동)
var sway = loop >= 0.55 ? Math.sin(loop * Math.PI * 5) * 0.028 : 0;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(sway);
ctx.translate(-cx, -cy);
drawTree(ctx, cx - s0 / 2, cy - s0, s0, 0, genF, seasonT);
ctx.restore();
}
// (x, y) = 정사각형 왼쪽 위, s = 한 변, gen = 남은 세대
function drawTree(ctx, x, y, s, gen, genF, seasonT) {
if (s < 0.9 || gen > genF) return;
var thisF = Math.min(1, genF - gen); // 현재 세대 안 진행률 — 부분 크기로 스르륵
var drawS = s * thisF;
if (drawS < 0.5) return;
// 색: 몸통(베이지) → 잎(계절 보간). 깊이 비율로 혼합
var depthT = Math.min(1, gen / MAXGEN);
var leaf = mix(C_LEAFA, C_LEAFB, seasonT);
var col = mix(C_TRUNK, leaf, depthT);
ctx.fillStyle = rgb(col);
// ctx.translate+rotate 대신 꼭짓점 직접 계산 — 정사각형이 기울어져도 fillRect 회전 근사가 아닌 정확 좌표
ctx.beginPath();
ctx.moveTo(x, y + s);
ctx.lineTo(x + s, y + s);
ctx.lineTo(x + s, y);
ctx.lineTo(x, y);
ctx.closePath();
// 성장 중 부분 크기: 아래 변 고정, 위로 줄어든 높이만큼만
if (thisF < 1) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y + s * (1 - thisF), s, s * thisF);
ctx.clip();
}
ctx.fill();
// 변 강조선 — 셸핀스키 카페트(05) 와 구별되는 정사각형 테두리
ctx.strokeStyle = 'rgba(' + C_LINE[0] + ',' + C_LINE[1] + ',' + C_LINE[2] + ',0.28)';
ctx.lineWidth = 0.75;
ctx.stroke();
if (thisF < 1) ctx.restore();
if (gen + 1 > genF) return;
// 위에 직각삼각형(45-45-90) — 빗변이 이 변. 두 정사각형을 빗변에 얹는다.
// 빗변 양끝 (x, y) (x+s, y). 45도 삼각형의 꼭대기는 중점 위 s/2.
var mx = x + s / 2, my = y - s / 2;
var NS = s / Math.SQRT2;
// 왼쪽 사각형: 빗변 (x,y)-(mx,my). 변 NS.
var angL = -Math.PI / 4; // -45도
var lx = x, ly = y;
// 왼쪽 사각형의 왼쪽 위 꼭짓점 = 빗변 시작점에서 밖으로 NS 만큼
var lcx = lx + Math.cos(-Math.PI * 3 / 4) * NS; // 225도 방향
var lcy = ly + Math.sin(-Math.PI * 3 / 4) * NS;
drawTree(ctx, lcx, lcy, NS, gen + 1, genF, seasonT);
// 오른쪽 사각형: 빗변 (mx,my)-(x+s,y).
var angR = Math.PI / 4;
var rx = mx, ry = my;
var rcx = rx + Math.cos(Math.PI * 3 / 4) * NS; // 135도 방향
var rcy = ry + Math.sin(Math.PI * 3 / 4) * NS;
drawTree(ctx, rcx, rcy, NS, gen + 1, genF, seasonT);
// 미세 진동 — 숲이 다 자란 뒤 미세하게 숨쉰다(눈에 거스리지 않는 0.3px)
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.6 : 0.8);
});
fit();
if (reduce) {
render(0.6);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysPythagorasTree — vanilla 판(run/328/demo/02-lsys-pythagoras-tree/index.html) 의 React 포트.
// 정사각형 위에 직각삼각형(빗변을 아래 변으로)을 얹고 그 빗변에 다시 정사각형 둘을 붙이는 재귀.
// 움직임 = 세대 성장(0→8) + 자란 뒤 계절 색 순환(초록 여름 → 호박 가을 → 다시 봄) + 미세 흔들림.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysPythagorasTree.module.scss";
export type LsysPythagorasTreeProps = {
duration?: string;
easing?: string;
color?: string;
side0?: number; // 첫 정사각형 한 변(무대 세로 배율)
cycle?: number;
maxGen?: number; // 최대 세대
};
const SIDE0 = 0.21;
const CYCLE = 2.0;
const MAXGEN = 8;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const HINT_TEXT = "정사각형 위에 빗변을 밑변으로 삼는 정사각형 둘을 얹는 규칙을 0 → 1 → 2 세대로 되짚으면 관 모양 숲이 자라는데, 세대가 늘수록 면은 줄고 변은 늘어나 프랙탈 차원이 2 에 다가갑니다";
const META_LEFT = "한 변 s · 축소 0.707 · 세대 0 → 8";
const META_RIGHT = "canvas · fillRect · requestAnimationFrame";
const FIELD_LABEL = "피타고라스 나무 실험판";
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgb(c: RGB): string {
return "rgb(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + ")";
}
export default function LsysPythagorasTree({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
side0 = SIDE0,
cycle = CYCLE,
maxGen = MAXGEN
}: LsysPythagorasTreeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ trunk: RGB; leafA: RGB; leafB: RGB; ink: RGB; line: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, gen: number, genF: number, seasonT: number): void {
const col = colRef.current;
if (!col) return;
if (s < 0.9 || gen > genF) return;
const thisF = Math.min(1, genF - gen);
const drawS = s * thisF;
if (drawS < 0.5) return;
const depthT = Math.min(1, gen / maxGen);
const leaf = mix(col.leafA, col.leafB, seasonT);
const c = mix(col.trunk, leaf, depthT);
ctx.fillStyle = rgb(c);
ctx.beginPath();
ctx.moveTo(x, y + s);
ctx.lineTo(x + s, y + s);
ctx.lineTo(x + s, y);
ctx.lineTo(x, y);
ctx.closePath();
if (thisF < 1) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y + s * (1 - thisF), s, s * thisF);
ctx.clip();
}
ctx.fill();
ctx.strokeStyle = "rgba(" + col.line[0] + "," + col.line[1] + "," + col.line[2] + ",0.28)";
ctx.lineWidth = 0.75;
ctx.stroke();
if (thisF < 1) ctx.restore();
if (gen + 1 > genF) return;
const mx = x + s / 2, my = y - s / 2;
const NS = s / Math.SQRT2;
const lx = x, ly = y;
const lcx = lx + Math.cos(-Math.PI * 3 / 4) * NS;
const lcy = ly + Math.sin(-Math.PI * 3 / 4) * NS;
drawTree(ctx, lcx, lcy, NS, gen + 1, genF, seasonT);
const rx = mx, ry = my;
const rcx = rx + Math.cos(Math.PI * 3 / 4) * NS;
const rcy = ry + Math.sin(Math.PI * 3 / 4) * NS;
drawTree(ctx, rcx, rcy, NS, gen + 1, genF, seasonT);
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
const grPhase = Math.min(loop / 0.55, 1);
const genF = grPhase * maxGen;
let seasonT = 0.5 - 0.5 * Math.cos((loop - 0.55) / 0.45 * Math.PI * 2);
if (loop < 0.55) seasonT = 0;
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const s0 = H * side0;
const cx = W * 0.5, cy = H * 0.94;
const sway = loop >= 0.55 ? Math.sin(loop * Math.PI * 5) * 0.028 : 0;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(sway);
ctx.translate(-cx, -cy);
drawTree(ctx, cx - s0 / 2, cy - s0, s0, 0, genF, seasonT);
ctx.restore();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
trunk: tokRGB(root, "--ls-bark"),
leafA: tokRGB(root, "--ls-leaf"),
leafB: tokRGB(root, "--ls-leaf2"),
ink: tokRGB(root, "--ls-ink"),
line: tokRGB(root, "--ls-bark2")
};
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.6);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [side0, cycle, maxGen]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}02 · 피타고라스 나무 — 정사각형 위에 정사각형 — 측정 누적면적 1.792% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
꼭짓점을 직접 계산하는 정사각형 재귀
function drawTree(ctx, x, y, s, gen, genF, seasonT) {
if (s < 0.9 || gen > genF) return;
var thisF = Math.min(1, genF - gen); // 현재 세대 안 진행률 — 부분 크기로 스르륵
var drawS = s * thisF;
if (drawS < 0.5) return;
// 색: 몸통(베이지) → 잎(계절 보간). 깊이 비율로 혼합
var depthT = Math.min(1, gen / MAXGEN);
var leaf = mix(C_LEAFA, C_LEAFB, seasonT);
var col = mix(C_TRUNK, leaf, depthT);
ctx.fillStyle = rgb(col);
// ctx.translate+rotate 대신 꼭짓점 직접 계산 — 정사각형이 기울어져도 fillRect 회전 근사가 아닌 정확 좌표
ctx.beginPath();
ctx.moveTo(x, y + s);
ctx.lineTo(x + s, y + s);
ctx.lineTo(x + s, y);
ctx.lineTo(x, y);
ctx.closePath();
빗변 위에 두 사각형 올리기
if (gen + 1 > genF) return;
// 위에 직각삼각형(45-45-90) — 빗변이 이 변. 두 정사각형을 빗변에 얹는다.
// 빗변 양끝 (x, y) (x+s, y). 45도 삼각형의 꼭대기는 중점 위 s/2.
var mx = x + s / 2, my = y - s / 2;
var NS = s / Math.SQRT2;
// 왼쪽 사각형: 빗변 (x,y)-(mx,my). 변 NS.
var lx = x, ly = y;
// 왼쪽 사각형의 왼쪽 위 꼭짓점 = 빗변 시작점에서 밖으로 NS 만큼
var lcx = lx + Math.cos(-Math.PI * 3 / 4) * NS; // 225도 방향
var lcy = ly + Math.sin(-Math.PI * 3 / 4) * NS;
drawTree(ctx, lcx, lcy, NS, gen + 1, genF, seasonT);
첫 정사각형 변은 무대 배율 0.21 이고, 완성 뒤엔 밑동을 고정한 미세 진동으로 화면이 죽지 않게 합니다.
03코흐 눈송이 — 변을 들어올려 무한 해안선
axiom F--F--F(정삼각형 세 변)에 규칙 F→F+F--F+F 를 세대마다 적용하면 매번 변 하나가 더 잘게 접혀 눈송이 해안선이 길어지는, 프랙탈 하면 떠오르는 대표 곡선입니다. 각도는 60°, 세대 0→4 로 보간해 이전 세대 점과 다음 세대 점을 섞어 부드럽게 넘어갑니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>F → F+F--F+F 문자 재쓰기 한 줄을 반복하면 삼각형 세 변이 매번 더 잘게 접혀 눈송이 해안선이 길어지는데, 세대가 늘수록 둘레는 늘고 안쪽 넓이는 거의 그대로라 프랙탈 차원이 드러납니다</span>
</p>
<div class="ls__field" role="application" aria-label="코흐 눈송이 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">규칙 F→F+F--F+F · 각도 60° · 세대 <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · 문자 재쓰기 · strokeStyle</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 코흐 눈송이(03). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(세대 크로스페이드 성장 + 둘레를 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01·02 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 코흐 눈송이(03) — 정삼각형(F--F--F)의 각 변을 F+F--F+F 로 되풀이해 다시 쓰면
// 가운데를 들어올린 산 모양이 매번 새로 끼어들어 해안선이 무한히 길어진다(둘레 → ∞, 넓이 → 유한).
// 움직임 = 세대 크로스페이드 성장(0→4) + 완성 뒤 둘레를 도는 빛 한 점(strokeStyle 순환).
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 60;
var MAXGEN = 4;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'F': 'F+F--F+F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — 문자열을 점 목록(닫힌 다각형)으로 적분
function turtlePoly(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a -= ANGLE * Math.PI / 180;
} else if (ch === '-') {
a += ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'F--F--F';
var POLY = []; // 세대별 정규화 점 목록(0~1 정사각 프레임)
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
// gen 0 은 변 길이 1로, 세대가 늘수록 변 개수가 3*4^g — step 을 3^g 에 반비례시켜 전체 크기를 맞춘다
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) {
var step = 1 / Math.pow(3, g2) * (g2 === 0 ? 1 : 1);
raw.push(turtlePoly(strs[g2], step));
}
// 최종 세대 기준 bbox 로 전체를 정규화(모든 세대가 같은 프레임 안에 겹치도록)
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPoly(ctx, pts, ox, oy, sc, col, alpha, lw, fillAlpha) {
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.closePath();
if (fillAlpha > 0) {
ctx.fillStyle = rgb(col, fillAlpha);
ctx.fill();
}
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.78;
var ox = (W - sc) / 2, oy = (H - sc) / 2 + H * 0.03;
// 0~0.68 : 세대 크로스페이드 성장. 0.68~1.0 : 완성 눈송이 + 도는 빛
var grPhase = Math.min(loop / 0.68, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
// 안쪽 반투명 채움 — 윤곽선만으로는 면적이 작아 보인다. 채움 알파를 계속 미세 진동시켜
// 완성 뒤(정지 구간)에도 픽셀이 계속 갱신되게 한다(피사체를 키운다 원칙).
var fillA = 0.16 + 0.06 * Math.sin(loop * Math.PI * 5);
drawPoly(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, 1.4, fillA);
if (f > 0 && gB !== gA) drawPoly(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, 1.2, 0);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.68) {
// 둘레를 도는 빛 — 최종 세대 점 목록을 따라 위치를 진행시킨다(strokeStyle 색 순환)
var t = (loop - 0.68) / 0.32;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[0];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
var hue = 0.5 + 0.5 * Math.sin(loop * Math.PI * 4);
var glowCol = [C_GLOW[0] + (255 - C_GLOW[0]) * hue * 0.4, C_GLOW[1], C_GLOW[2] + (255 - C_GLOW[2]) * 0.2];
ctx.fillStyle = rgb(glowCol, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgb(glowCol, 0.35);
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(lx, ly, 5.5, 0, Math.PI * 2);
ctx.stroke();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysKochSnowflake — vanilla 판(run/328/demo/03-lsys-koch-snowflake/index.html) 의 React 포트.
// 정삼각형(F--F--F)의 각 변을 F+F--F+F 로 되풀이해 다시 쓰면 해안선이 무한히 길어진다(둘레 → ∞, 넓이 → 유한).
// 움직임 = 세대 크로스페이드 성장(0→4) + 완성 뒤 둘레를 도는 빛 한 점(strokeStyle 순환).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysKochSnowflake.module.scss";
export type LsysKochSnowflakeProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 60;
const MAXGEN = 4;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "F → F+F--F+F 문자 재쓰기 한 줄을 반복하면 삼각형 세 변이 매번 더 잘게 접혀 눈송이 해안선이 길어지는데, 세대가 늘수록 둘레는 늘고 안쪽 넓이는 거의 그대로라 프랙탈 차원이 드러납니다";
const META_LEFT_PREFIX = "규칙 F→F+F--F+F · 각도 60° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · strokeStyle";
const FIELD_LABEL = "코흐 눈송이 실험판";
const RULE: Record<string, string> = { F: "F+F--F+F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePoly(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a -= angle * Math.PI / 180;
} else if (ch === "-") {
a += angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "F--F--F";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) {
const step = 1 / Math.pow(3, g2);
raw.push(turtlePoly(strs[g2], step, angle));
}
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysKochSnowflake({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysKochSnowflakeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPoly(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number, fillAlpha: number): void {
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.closePath();
if (fillAlpha > 0) {
ctx.fillStyle = rgba(col, fillAlpha);
ctx.fill();
}
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.78;
const ox = (W - sc) / 2, oy = (H - sc) / 2 + H * 0.03;
const grPhase = Math.min(loop / 0.68, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
const fillA = 0.16 + 0.06 * Math.sin(loop * Math.PI * 5);
drawPoly(ctx, POLY[gA], ox, oy, sc, col.line, 1, 1.4, fillA);
if (f > 0 && gB !== gA) drawPoly(ctx, POLY[gB], ox, oy, sc, col.glow, f, 1.2, 0);
setGen(gA);
if (loop >= 0.68) {
const t = (loop - 0.68) / 0.32;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[0];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
const hue = 0.5 + 0.5 * Math.sin(loop * Math.PI * 4);
const glowCol: RGB = [col.glow[0] + (255 - col.glow[0]) * hue * 0.4, col.glow[1], col.glow[2] + (255 - col.glow[2]) * 0.2];
ctx.fillStyle = rgba(glowCol, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgba(glowCol, 0.35);
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(lx, ly, 5.5, 0, Math.PI * 2);
ctx.stroke();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}03 · 코흐 눈송이 — 변을 들어올려 무한 해안선 — 측정 누적면적 8.315% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
닫힌 다각형으로 해석하는 거북
var RULE = { 'F': 'F+F--F+F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — 문자열을 점 목록(닫힌 다각형)으로 적분
function turtlePoly(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
axiom 이 F--F--F 세 변이어야 정삼각형에서 시작해 해안선이 안으로 접히는데, 다른 axiom 이면 같은 이름의 도형이 나오지 않습니다.
04셸핀스키 삼각형 — 숨은 삼각형의 삼각형
axiom A 에 A→B-A-B, B→A+B+A 두 문자가 서로를 다시 쓰는 규칙을 60° 거북 걸음으로 풀면 한붓그리기 화살촉 곡선이 되는, 빈틈이 메워져 가는 과정이 눈에 보이는 항목입니다. 세대 0→6 으로 늘수록 그 곡선이 셸핀스키 삼각형의 빈틈을 그대로 메워 갑니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>A→B-A-B, B→A+B+A 두 문자를 60° 거북 걸음으로 풀면 한붓그리기 화살촉 곡선이 되는데, 세대를 더할수록 그 곡선이 셸핀스키 삼각형의 빈틈을 그대로 메웁니다</span>
</p>
<div class="ls__field" role="application" aria-label="셸핀스키 삼각형 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">규칙 A→B-A-B · 각도 60° · 세대 <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · 문자 재쓰기 · beginPath</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 셸핀스키 삼각형(04). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(화살촉 곡선 세대 크로스페이드 + 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~03 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 셸핀스키 삼각형(04) — 화살촉(Arrowhead) L-시스템: A,B 두 기호가 서로를 끼워 넣으며
// 자라는 한붓그리기 곡선이 세대가 늘수록 삼각형 가스켓의 구멍을 정확히 남긴다.
// 움직임 = 세대 크로스페이드 성장(0→6) + 완성 뒤 곡선을 따라 도는 빛 한 점.
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 60;
var MAXGEN = 6;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'A': 'B-A-B', 'B': 'A+B+A' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — A,B 모두 전진 한 걸음, +,- 는 60도 회전. 열린 곡선 점 목록을 만든다.
function turtlePath(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'A' || ch === 'B') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a -= ANGLE * Math.PI / 180;
} else if (ch === '-') {
a += ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'A';
var POLY = [];
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) raw.push(turtlePath(strs[g2], 1 / Math.pow(2, g2)));
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPath(ctx, pts, ox, oy, sc, col, alpha, lw) {
if (!pts.length) return;
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.82;
var ox = (W - sc) / 2, oy = (H - sc) / 2;
var grPhase = Math.min(loop / 0.68, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
// 세대가 늘수록 선분 간격이 좁아지므로 굵기를 간격에 맞춰 키운다 —
// 화살촉 곡선이 스스로 겹치며 가스켓 안쪽을 실제로 채운다(피사체를 키운다 원칙)
var lwA = Math.min(4.2, Math.max(1.3, sc / Math.pow(2, gA) * 0.62));
var lwB = Math.min(4.2, Math.max(1.1, sc / Math.pow(2, gB) * 0.62));
drawPath(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, lwA);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, lwB);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.68) {
var t = (loop - 0.68) / 0.32;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgb(C_GLOW, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3, 0, Math.PI * 2);
ctx.fill();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysSierpinskiTriangle — vanilla 판(run/328/demo/04-lsys-sierpinski-triangle/index.html) 의 React 포트.
// 화살촉(Arrowhead) L-시스템: A,B 두 기호가 서로를 끼워 넣으며 자라는 한붓그리기 곡선이
// 세대가 늘수록 삼각형 가스켓의 구멍을 정확히 남긴다.
// 움직임 = 세대 크로스페이드 성장(0→6) + 완성 뒤 곡선을 따라 도는 빛 한 점.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysSierpinskiTriangle.module.scss";
export type LsysSierpinskiTriangleProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 60;
const MAXGEN = 6;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "A→B-A-B, B→A+B+A 두 문자를 60° 거북 걸음으로 풀면 한붓그리기 화살촉 곡선이 되는데, 세대를 더할수록 그 곡선이 셸핀스키 삼각형의 빈틈을 그대로 메웁니다";
const META_LEFT_PREFIX = "규칙 A→B-A-B · 각도 60° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · beginPath";
const FIELD_LABEL = "셸핀스키 삼각형 실험판";
const RULE: Record<string, string> = { A: "B-A-B", B: "A+B+A" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePath(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "A" || ch === "B") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a -= angle * Math.PI / 180;
} else if (ch === "-") {
a += angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "A";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) raw.push(turtlePath(strs[g2], 1 / Math.pow(2, g2), angle));
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysSierpinskiTriangle({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysSierpinskiTriangleProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPath(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number): void {
if (!pts.length) return;
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.82;
const ox = (W - sc) / 2, oy = (H - sc) / 2;
const grPhase = Math.min(loop / 0.68, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
const lwA = Math.min(4.2, Math.max(1.3, sc / Math.pow(2, gA) * 0.62));
const lwB = Math.min(4.2, Math.max(1.1, sc / Math.pow(2, gB) * 0.62));
drawPath(ctx, POLY[gA], ox, oy, sc, col.line, 1, lwA);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, col.glow, f, lwB);
setGen(gA);
if (loop >= 0.68) {
const t = (loop - 0.68) / 0.32;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgba(col.glow, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3, 0, Math.PI * 2);
ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}04 · 셸핀스키 삼각형 — 숨은 삼각형의 삼각형 — 측정 누적면적 5.87% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
두 문자가 서로를 다시 쓰는 규칙
var RULE = { 'A': 'B-A-B', 'B': 'A+B+A' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
A·B 모두 전진하는 열린 곡선
// 거북 해석 — A,B 모두 전진 한 걸음, +,- 는 60도 회전. 열린 곡선 점 목록을 만든다.
function turtlePath(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'A' || ch === 'B') {
두 규칙을 하나로 합치면 서로가 서로를 다시 쓰는 구조가 사라져 다른 곡선이 나옵니다.
05셸핀스키 카펫 — 칸을 비우며 파고드는 구멍
채움 칸 하나(문자 1)를 3×3 아홉 글자로 다시 쓰되 가운데만 구멍(문자 0)으로 남기는 규칙 1→111101111 을 세대 0→4 로 적용해 정사각형을 fillRect 격자로 그리는, 면이 줄고 가장자리가 늘어나는 과정을 실측으로 보여주는 항목입니다. getImageData 로 실제 채워진 픽셀 비율을 세어 남은 넓이 % 를 화면에 그대로 표시하는데 이는 트윈 값이 아니라 실측값입니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>문자 1(채움)을 3×3 아홉 칸 중 가운데만 0(구멍)으로 바꾼 아홉 글자로 되풀이해 바꿔 쓰면, 세대가 늘수록 남은 넓이가 8/9 씩 줄어드는 카펫이 뚫립니다 — getImageData 로 남은 넓이를 직접 셉니다</span>
</p>
<div class="ls__field" role="application" aria-label="셸핀스키 카펫 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">세대 <b id="lsGen">0</b> · 넓이 <b id="lsArea">100%</b></span>
<span class="ls__meta ls__meta--dim">fillRect · getImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 셸핀스키 카펫(05). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(격자 문자 재쓰기 세대 성장 + 밝기 호흡)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~04 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 셸핀스키 카펫(05) — 채움 칸 하나(1)를 3×3 아홉 칸으로 다시 쓰되 가운데만 구멍(0)으로
// 남기는 문자 재쓰기(1→111101111)를 세대마다 8칸에 재귀 적용한다. 채워진 픽셀 수를
// getImageData 로 직접 세어 "남은 넓이 %" 를 화면에 표시 — 트윈이 아니라 실측값이다.
(function () {
// ── 변형 상수 블록 ──
var MAXGEN = 4;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var areaEl = document.getElementById('lsArea');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FILL = tokRGB('--ls-bark2');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
var lastAreaTick = -1;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 문자 재쓰기 규칙 — '1' 한 칸이 3x3 아홉 칸(가운데만 구멍)으로 다시 쓰인다.
var RULE = { '1': '111101111', '0': '000000000' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) out += RULE[s.charAt(i)];
s = out;
}
return s;
}
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
// gen 세대 문자열을 3^gen x 3^gen 격자로 해석해 채워진 칸만 fillRect
function drawCarpet(ctx, str, gen, ox, oy, side, thisF, bright) {
var n = Math.pow(3, gen);
var cell = side / n;
for (var idx = 0; idx < str.length; idx++) {
if (str.charAt(idx) === '0') continue;
var row = Math.floor(idx / n), col = idx % n;
var cx = ox + col * cell, cy = oy + row * cell;
var pad = cell * (1 - thisF) * 0.5;
ctx.fillRect(cx + pad, cy + pad, Math.max(0.4, cell - pad * 2), Math.max(0.4, cell - pad * 2));
}
}
function render(loop, now) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.84;
var ox = (W - side) / 2, oy = (H - side) / 2;
// 0~0.6 : 세대 성장(0→MAXGEN). 0.6~1.0 : 완성 카펫 + 밝기 호흡(자기유사 강조)
var grPhase = Math.min(loop / 0.6, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var thisF = gA >= MAXGEN ? 1 : Math.min(1, genF - gA);
var str = expand('1', gA);
var breath = loop < 0.6 ? 1 : 0.72 + 0.28 * Math.sin((loop - 0.6) / 0.4 * Math.PI * 3);
ctx.fillStyle = rgb(C_FILL, breath);
drawCarpet(ctx, str, gA, ox, oy, side, thisF, breath);
if (genEl) genEl.textContent = String(gA);
// 남은 넓이 % — getImageData 로 실제 채워진(배경이 아닌) 픽셀 비율을 센다. 매 프레임 다시 셀 필요는
// 없으므로(값은 세대에만 의존) 세대가 바뀔 때만 재계산해 계산량을 아낀다.
if (areaEl && gA !== lastAreaTick) {
lastAreaTick = gA;
try {
var img = ctx.getImageData(Math.round(ox), Math.round(oy), Math.round(side), Math.round(side));
var data = img.data, filled = 0, total = data.length / 4;
for (var p = 0; p < data.length; p += 4) {
if (data[p] !== C_INK[0] || data[p + 1] !== C_INK[1] || data[p + 2] !== C_INK[2]) filled++;
}
areaEl.textContent = Math.round((filled / total) * 100) + '%';
} catch (e) { /* 캔버스 판독 실패해도 화면은 그대로 동작 */ }
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop, now);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
lastAreaTick = -1;
render(0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysSierpinskiCarpet — vanilla 판(run/328/demo/05-lsys-sierpinski-carpet/index.html) 의 React 포트.
// 채움 칸 하나(1)를 3×3 아홉 칸으로 다시 쓰되 가운데만 구멍(0)으로 남기는 문자 재쓰기(1→111101111)를
// 세대마다 8칸에 재귀 적용한다. 채워진 픽셀 수를 getImageData 로 직접 세어 "남은 넓이 %" 를 표시 — 실측값.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysSierpinskiCarpet.module.scss";
export type LsysSierpinskiCarpetProps = {
duration?: string;
easing?: string;
color?: string;
maxGen?: number;
cycle?: number;
};
const MAXGEN = 4;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const HINT_TEXT = "문자 1(채움)을 3×3 아홉 칸 중 가운데만 0(구멍)으로 바꾼 아홉 글자로 되풀이해 바꿔 쓰면, 세대가 늘수록 남은 넓이가 8/9 씩 줄어드는 카펫이 뚫립니다 — getImageData 로 남은 넓이를 직접 셉니다";
const META_RIGHT = "fillRect · getImageData";
const FIELD_LABEL = "셸핀스키 카펫 실험판";
const RULE: Record<string, string> = { "1": "111101111", "0": "000000000" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) out += RULE[s.charAt(i)];
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysSierpinskiCarpet({
duration = "2s",
easing = "ease-spring",
color = "#e6d8bc",
maxGen = MAXGEN,
cycle = CYCLE
}: LsysSierpinskiCarpetProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ fill: RGB; ink: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const lastAreaTickRef = useRef(-1) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const [area, setArea] = useState("100%");
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawCarpet(ctx: CanvasRenderingContext2D, str: string, gen: number, ox: number, oy: number, side: number, thisF: number): void {
const n = Math.pow(3, gen);
const cell = side / n;
for (let idx = 0; idx < str.length; idx++) {
if (str.charAt(idx) === "0") continue;
const row = Math.floor(idx / n), col = idx % n;
const cx = ox + col * cell, cy = oy + row * cell;
const pad = cell * (1 - thisF) * 0.5;
ctx.fillRect(cx + pad, cy + pad, Math.max(0.4, cell - pad * 2), Math.max(0.4, cell - pad * 2));
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.84;
const ox = (W - side) / 2, oy = (H - side) / 2;
const grPhase = Math.min(loop / 0.6, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const thisF = gA >= maxGen ? 1 : Math.min(1, genF - gA);
const str = expand("1", gA);
const breath = loop < 0.6 ? 1 : 0.72 + 0.28 * Math.sin((loop - 0.6) / 0.4 * Math.PI * 3);
ctx.fillStyle = rgba(col.fill, breath);
drawCarpet(ctx, str, gA, ox, oy, side, thisF);
setGen(gA);
if (gA !== lastAreaTickRef.current) {
lastAreaTickRef.current = gA;
try {
const img = ctx.getImageData(Math.round(ox), Math.round(oy), Math.round(side), Math.round(side));
const data = img.data;
let filled = 0;
const total = data.length / 4;
for (let p = 0; p < data.length; p += 4) {
if (data[p] !== col.ink[0] || data[p + 1] !== col.ink[1] || data[p + 2] !== col.ink[2]) filled++;
}
setArea(Math.round((filled / total) * 100) + "%");
} catch (e) { /* 캔버스 판독 실패해도 화면은 그대로 동작 */ }
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
fill: tokRGB(root, "--ls-bark2"),
ink: tokRGB(root, "--ls-ink")
};
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
lastAreaTickRef.current = -1;
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>세대 <b>{gen}</b> · 넓이 <b>{area}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}05 · 셸핀스키 카펫 — 칸을 비우며 파고드는 구멍 — 측정 누적면적 16.167% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
3^gen 격자 해석과 fillRect
// 문자 재쓰기 규칙 — '1' 한 칸이 3x3 아홉 칸(가운데만 구멍)으로 다시 쓰인다.
var RULE = { '1': '111101111', '0': '000000000' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) out += RULE[s.charAt(i)];
s = out;
}
return s;
}
// gen 세대 문자열을 3^gen x 3^gen 격자로 해석해 채워진 칸만 fillRect
function drawCarpet(ctx, str, gen, ox, oy, side, thisF, bright) {
var n = Math.pow(3, gen);
var cell = side / n;
남은 넓이를 직접 세는 getImageData
if (areaEl && gA !== lastAreaTick) {
lastAreaTick = gA;
try {
var img = ctx.getImageData(Math.round(ox), Math.round(oy), Math.round(side), Math.round(side));
var data = img.data, filled = 0, total = data.length / 4;
for (var p = 0; p < data.length; p += 4) {
if (data[p] !== C_INK[0] || data[p + 1] !== C_INK[1] || data[p + 2] !== C_INK[2]) filled++;
}
areaEl.textContent = Math.round((filled / total) * 100) + '%';
} catch (e) { /* 캔버스 판독 실패해도 화면은 그대로 동작 */ }
}
세대가 바뀔 때만 다시 세어 계산량을 아끼는데, 세대 4 면 채워진 칸 비율이 (8/9)⁴ ≈ 0.62 로 화면의 % 표시와 맞아떨어집니다.
06용 곡선 — 접기 규칙이 만드는 종이공룡
axiom FX 한 줄에 X→X+YF+, Y→-FX-Y 두 규칙을 되풀이 적용하면 종이를 반으로 접었다 편 자국처럼 겹치지 않고 빼곡히 접히는 용 곡선이 자라는, L시스템 두 문자의 상호 재쓰기로 그리는 대표 응용입니다. 각도는 90°, 세대 0→11 까지 늘려 접힘 밀도를 끝까지 채웁니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>FX 한 줄에서 X→X+YF+, Y→-FX-Y 두 규칙을 되풀이 적용하면 종이를 반으로 접었다 편 자국처럼 겹치지 않고 빼곡히 접히는 용(드래곤) 곡선이 자랍니다</span>
</p>
<div class="ls__field" role="application" aria-label="드래곤 커브 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">규칙 X→X+YF+ · 각도 90° · 세대 <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · 문자 재쓰기 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 드래곤 커브(06). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자 재쓰기 세대 크로스페이드 + 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~05 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 드래곤 커브(06) — 종이 접기를 문자로 옮긴 L-시스템(FX 축, X→X+YF+, Y→-FX-Y, 90도).
// 세대가 늘수록 접힌 자국(F 전진, +/- 90도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = 세대 크로스페이드 성장(0→11) + 완성 뒤 곡선을 따라 도는 빛 한 점.
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 90;
var MAXGEN = 11;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'X': 'X+YF+', 'Y': '-FX-Y' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — F 만 전진(점 추가), X·Y 는 자리표시자(전진 없음), +/- 는 90도 회전.
function turtlePath(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a += ANGLE * Math.PI / 180;
} else if (ch === '-') {
a -= ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'FX';
var POLY = [];
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) raw.push(turtlePath(strs[g2], 1));
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPath(ctx, pts, ox, oy, sc, col, alpha, lw) {
if (pts.length < 2) return;
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.8;
var ox = (W - sc) / 2, oy = (H - sc) / 2;
var grPhase = Math.min(loop / 0.7, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
drawPath(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, 2.1);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, 1.9);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.7) {
var t = (loop - 0.7) / 0.3;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgb(C_GLOW, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysDragonCurve — vanilla 판(run/328/demo/06-lsys-dragon-curve/index.html) 의 React 포트.
// 종이 접기를 문자로 옮긴 L-시스템(FX 축, X→X+YF+, Y→-FX-Y, 90도).
// 세대가 늘수록 접힌 자국(F 전진, +/- 90도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = 세대 크로스페이드 성장(0→11) + 완성 뒤 곡선을 따라 도는 빛 한 점.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysDragonCurve.module.scss";
export type LsysDragonCurveProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 90;
const MAXGEN = 11;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "FX 한 줄에서 X→X+YF+, Y→-FX-Y 두 규칙을 되풀이 적용하면 종이를 반으로 접었다 편 자국처럼 겹치지 않고 빼곡히 접히는 용(드래곤) 곡선이 자랍니다";
const META_LEFT_PREFIX = "규칙 X→X+YF+ · 각도 90° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · requestAnimationFrame";
const FIELD_LABEL = "드래곤 커브 실험판";
const RULE: Record<string, string> = { X: "X+YF+", Y: "-FX-Y" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePath(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a += angle * Math.PI / 180;
} else if (ch === "-") {
a -= angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "FX";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) raw.push(turtlePath(strs[g2], 1, angle));
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysDragonCurve({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysDragonCurveProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPath(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number): void {
if (pts.length < 2) return;
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.8;
const ox = (W - sc) / 2, oy = (H - sc) / 2;
const grPhase = Math.min(loop / 0.7, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
drawPath(ctx, POLY[gA], ox, oy, sc, col.line, 1, 2.1);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, col.glow, f, 1.9);
setGen(gA);
if (loop >= 0.7) {
const t = (loop - 0.7) / 0.3;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgba(col.glow, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 · 용 곡선 — 접기 규칙이 만드는 종이공룡 — 측정 누적면적 5.192% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
X·Y 자리표시자와 90도 회전
var RULE = { 'X': 'X+YF+', 'Y': '-FX-Y' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — F 만 전진(점 추가), X·Y 는 자리표시자(전진 없음), +/- 는 90도 회전.
function turtlePath(str, step) {
세대 11 이면 문자열 길이가 수십만 글자에 이르러, 세대별 사전 계산이 없으면 한 프레임에 전개를 다시 하는 순간 계산이 폭주합니다.
07매개변수 식물 — F+-[] 문자열이 자라는 잎
axiom X 에 X→F+[[X]-X]-F[-FX]+X, F→FF 두 규칙을 세대 0→5 로 적용하면 관엽식물 잎이 펼쳐지는, 몇 글자 고치면 완전히 다른 식물이 되는 생성문법 실험판입니다. 기준 분기각 25° 가 다 자란 뒤 ±6° 폭으로 사인파처럼 흔들려 같은 문법·다른 매개변수가 다른 식물임을 실시간으로 보여주고, 잎은 깊이에 따라 globalAlpha 로 옅어집니다.
![매개변수 식물 — F+-[] 문자열이 자라는 잎](https://ayo-oss.github.io/godrichstory-demo/lsystem-fractal-tree-canvas/07-lsys-plant-parametric/poster.png)
<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>X→F+[[X]-X]-F[-FX]+X 한 줄에 F→FF 를 더해 각도·비율 두 숫자만 바꾸면 고사리부터 관목까지 전혀 다른 식물이 같은 문법에서 자라납니다 — 매개변수가 종을 결정합니다</span>
</p>
<div class="ls__field" role="application" aria-label="매개변수 식물 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">규칙 X→F+[[X]-X]-F[-FX]+X · 각도 <b id="lsAng">25</b>°</span>
<span class="ls__meta ls__meta--dim">canvas · globalAlpha</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매개변수 식물(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자 재쓰기 세대 성장 + 분기각 매개변수 흔들림)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~06 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 매개변수 식물(07) — Prusinkiewicz 고전 식물 L-시스템(X 축, X→F+[[X]-X]-F[-FX]+X, F→FF).
// 대괄호 [ ] 로 위치·방향을 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = 세대 크로스페이드 성장(0→5) + 완성 뒤 각도를 서서히 흔들어(매개변수 자체가 시간 함수)
// "같은 문법·다른 매개변수 = 다른 식물"을 실시간으로 보여준다. 잎은 globalAlpha 로 깊이 페이드.
(function () {
// ── 변형 상수 블록 ──
var ANGLE0 = 25; // 기준 분기각(도) — 이 값이 흔들리며 식물 형태가 바뀐다
var SWING = 6; // 각도 흔들림 폭(도)
var MAXGEN = 5;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var angEl = document.getElementById('lsAng');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_STEM = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'X': 'F+[[X]-X]-F[-FX]+X', 'F': 'FF' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
var STRS = [];
for (var g0 = 0; g0 <= MAXGEN; g0++) STRS.push(expand('X', g0));
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 거북 렌더러 — angleDeg 는 매 프레임 바뀌는 매개변수(같은 문자열, 다른 각도 → 다른 식물)
function drawPlant(ctx, str, x0, y0, stepLen, angleDeg, ox, oy, sc, alphaMul, drawFrac) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var n = str.length;
var drawUpto = Math.floor(n * drawFrac);
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var nx = x + Math.cos(a) * stepLen;
var ny = y + Math.sin(a) * stepLen;
var depth = stack.length;
var t = Math.min(1, depth / 6);
var cr = C_STEM[0] + (C_LEAF[0] - C_STEM[0]) * t;
var cg = C_STEM[1] + (C_LEAF[1] - C_STEM[1]) * t;
var cb = C_STEM[2] + (C_LEAF[2] - C_STEM[2]) * t;
ctx.globalAlpha = alphaMul * (0.55 + 0.45 * t);
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(0.9, 3.2 * Math.pow(0.72, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= 4) {
ctx.globalAlpha = alphaMul * 0.5;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 3.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.fill();
}
x = nx; y = ny;
} else if (ch === '+') {
a += angleDeg * Math.PI / 180;
} else if (ch === '-') {
a -= angleDeg * Math.PI / 180;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a });
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; }
}
}
ctx.globalAlpha = 1;
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.16;
var ox = W * 0.5, oy = H * 0.95;
var grPhase = Math.min(loop / 0.55, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var drawFrac = gA >= MAXGEN ? 1 : Math.min(1, genF - gA);
// 성장 뒤 각도를 흔들어 매개변수가 형태를 바꾸는 걸 실시간으로 보여준다
var angT = loop >= 0.55 ? (loop - 0.55) / 0.45 : 0;
var angle = ANGLE0 + (loop >= 0.55 ? Math.sin(angT * Math.PI * 3) * SWING : 0);
if (angEl) angEl.textContent = String(Math.round(angle));
drawPlant(ctx, STRS[gA], 0, 0, 1 / Math.pow(1.55, gA) * 0.62, angle, ox, oy, sc, 1, gA >= MAXGEN ? 1 : drawFrac);
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysPlantParametric — vanilla 판(run/328/demo/07-lsys-plant-parametric/index.html) 의 React 포트.
// Prusinkiewicz 고전 식물 L-시스템(X 축, X→F+[[X]-X]-F[-FX]+X, F→FF). 대괄호 [ ] 로 위치·방향을
// 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = 세대 크로스페이드 성장(0→5) + 완성 뒤 각도를 서서히 흔들어 "같은 문법·다른 매개변수 = 다른 식물"을 보여준다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysPlantParametric.module.scss";
export type LsysPlantParametricProps = {
duration?: string;
easing?: string;
color?: string;
angle0?: number; // 기준 분기각(도)
swing?: number; // 각도 흔들림 폭(도)
maxGen?: number;
cycle?: number;
};
const ANGLE0 = 25;
const SWING = 6;
const MAXGEN = 5;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number };
const HINT_TEXT = "X→F+[[X]-X]-F[-FX]+X 한 줄에 F→FF 를 더해 각도·비율 두 숫자만 바꾸면 고사리부터 관목까지 전혀 다른 식물이 같은 문법에서 자라납니다 — 매개변수가 종을 결정합니다";
const META_LEFT_PREFIX = "규칙 X→F+[[X]-X]-F[-FX]+X · 각도 ";
const META_RIGHT = "canvas · globalAlpha";
const FIELD_LABEL = "매개변수 식물 실험판";
const RULE: Record<string, string> = { X: "F+[[X]-X]-F[-FX]+X", F: "FF" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysPlantParametric({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle0 = ANGLE0,
swing = SWING,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysPlantParametricProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ stem: RGB; leaf: RGB; ink: RGB } | null>(null);
const strsRef = useRef<string[]>([]) as Box<string[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [ang, setAng] = useState(ANGLE0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPlant(ctx: CanvasRenderingContext2D, str: string, x0: number, y0: number, stepLen: number, angleDeg: number, ox: number, oy: number, sc: number, alphaMul: number, drawFrac: number): void {
const col = colRef.current;
if (!col) return;
let x = x0, y = y0, a = -Math.PI / 2;
const stack: Stack[] = [];
const n = str.length;
const drawUpto = Math.floor(n * drawFrac);
for (let i = 0; i < drawUpto; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const nx = x + Math.cos(a) * stepLen;
const ny = y + Math.sin(a) * stepLen;
const depth = stack.length;
const t = Math.min(1, depth / 6);
const cr = col.stem[0] + (col.leaf[0] - col.stem[0]) * t;
const cg = col.stem[1] + (col.leaf[1] - col.stem[1]) * t;
const cb = col.stem[2] + (col.leaf[2] - col.stem[2]) * t;
ctx.globalAlpha = alphaMul * (0.55 + 0.45 * t);
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.9, 3.2 * Math.pow(0.72, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= 4) {
ctx.globalAlpha = alphaMul * 0.5;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 3.2, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.fill();
}
x = nx; y = ny;
} else if (ch === "+") {
a += angleDeg * Math.PI / 180;
} else if (ch === "-") {
a -= angleDeg * Math.PI / 180;
} else if (ch === "[") {
stack.push({ x, y, a });
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; }
}
}
ctx.globalAlpha = 1;
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const STRS = strsRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !STRS.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.16;
const ox = W * 0.5, oy = H * 0.95;
const grPhase = Math.min(loop / 0.55, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const drawFrac = gA >= maxGen ? 1 : Math.min(1, genF - gA);
const angT = loop >= 0.55 ? (loop - 0.55) / 0.45 : 0;
const angle = angle0 + (loop >= 0.55 ? Math.sin(angT * Math.PI * 3) * swing : 0);
setAng(Math.round(angle));
drawPlant(ctx, STRS[gA], 0, 0, 1 / Math.pow(1.55, gA) * 0.62, angle, ox, oy, sc, 1, gA >= maxGen ? 1 : drawFrac);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
stem: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
const strs: string[] = [];
for (let g0 = 0; g0 <= maxGen; g0++) strs.push(expand("X", g0));
strsRef.current = strs;
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle0, swing, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{ang}</b>°</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}07 · 매개변수 식물 — F+-[] 문자열이 자라는 잎 — 측정 누적면적 6.449% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
매 프레임 바뀌는 각도 매개변수
var RULE = { 'X': 'F+[[X]-X]-F[-FX]+X', 'F': 'FF' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
var STRS = [];
for (var g0 = 0; g0 <= MAXGEN; g0++) STRS.push(expand('X', g0));
깊이별 색 보간과 스택
// 거북 렌더러 — angleDeg 는 매 프레임 바뀌는 매개변수(같은 문자열, 다른 각도 → 다른 식물)
function drawPlant(ctx, str, x0, y0, stepLen, angleDeg, ox, oy, sc, alphaMul, drawFrac) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var n = str.length;
var drawUpto = Math.floor(n * drawFrac);
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
문자열은 그대로 두고 angleDeg 만 흔들어도 잎 전체의 실루엣이 숨 쉬는 것이 이 판의 계산 겹입니다.
08바람에 흔들리는 나무 — 세대마다 위상이 어긋나는 진동
규칙 F→F[+F]F-F는 그대로 두고, 거북이 매 걸음마다 꺾는 각도에 깊이 비례하는 사인파(진동수 1.6회/주기, 최대 진폭 30°)를 곱해 더하는 바람 판입니다. 가지 길이도 깊이마다 0.78 배씩 줄어 뿌리는 거의 안 움직이고 잔가지 끝만 크게 흔들립니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>F→F[+F]F[-F] 문자열은 그대로 두고, 거북이 매 걸음마다 꺾는 각도에 세대 깊이만큼 커지는 사인파를 더하면 뿌리는 그대로인데 잔가지 끝만 크게 흔들리는 바람이 됩니다</span>
</p>
<div class="ls__field" role="application" aria-label="바람 흔들림 나무 실험판" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">규칙 F→F[+F]F[-F] · 바람 세기 <b id="lsWind">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · globalAlpha</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 바람 흔들림 나무(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(고정 문자열 + 깊이비례 사인파 흔들림)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~07 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 바람 흔들림 나무(08) — 문자열 자체(F→F[+F]F[-F])는 고정. 거북이 걸을 때 방향각에
// "깊이(세대)에 비례하는 사인파"를 더해서, 같은 문자열이 매 프레임 다른 모양으로 보인다.
// 뿌리는 위상이 0 이라 거의 안 흔들리고, 잎 끝은 깊이가 깊어 크게 흔들린다(자연스러운 바람 감쇠).
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 24;
var MAXGEN = 5;
var WIND_AMP = 30; // 잎 끝 최대 흔들림(도)
var WIND_HZ = 1.6; // 바람 진동수(회/루프)
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var windEl = document.getElementById('lsWind');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'F': 'F[+F]F[-F]' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
var STR = expand('F', MAXGEN);
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 거북 렌더러 — 매 F 걸음마다 깊이 비례 사인파를 각도에 더해(바람) 그린다.
function drawWind(ctx, str, x0, y0, stepLen, ox, oy, sc, windPhase, windAmp, windGust) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var depth = 0, maxDepth = MAXGEN;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
// 세대 깊이에 비례(선형)해 커지는 사인파 — 잔가지뿐 아니라 중간 가지도 함께 휘어야
// 프레임마다 바뀌는 픽셀 면적이 뿌리 근처 몇 개 선분에만 갇히지 않는다.
var sway = windAmp * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = stepLen * Math.pow(0.78, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.globalAlpha = 0.6 + 0.4 * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(1.5, 5.2 * Math.pow(0.66, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= maxDepth - 1) {
// 잎 끝 무리 — 반경 자체도 돌풍 세기를 따라 커지고 작아져 정지 상태로 겹치지 않는다.
var glowR = 7 + 5 * windGust;
var coreR = 3 + 2 * windGust;
ctx.fillStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.globalAlpha = 0.2;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.62;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, coreR, 0, Math.PI * 2);
ctx.fill();
}
x = nx; y = ny;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a, d: depth });
depth++;
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
} else if (ch === '+') {
a += ANGLE * Math.PI / 180;
} else if (ch === '-') {
a -= ANGLE * Math.PI / 180;
}
}
ctx.globalAlpha = 1;
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.24;
var ox = W * 0.5, oy = H * 0.95;
var windPhase = loop * Math.PI * 2 * WIND_HZ;
var windGust = 0.55 + 0.45 * Math.sin(loop * Math.PI * 2 * 0.5); // 돌풍 세기 자체도 서서히 오르내린다
var windAmp = WIND_AMP * windGust;
if (windEl) windEl.textContent = String(Math.round(windGust * 10));
drawWind(ctx, STR, 0, 0, 1, ox, oy, sc, windPhase, windAmp, windGust);
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(0.3);
});
fit();
if (reduce) {
render(0.3);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysTreeWindSway — vanilla 판(run/328/demo/08-lsys-tree-wind-sway/index.html) 의 React 포트.
// F 축 나무(F→F[+F]F[-F]F, 24도)를 완성해 둔 채 각 마디의 흔들림 위상을 깊이(depth)에 비례해 지연시켜
// 바람이 뿌리에서 가지 끝으로 퍼져 나가는 것처럼 보이게 한다. 세기는 sin(t·WIND_HZ) 로 계산.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysTreeWindSway.module.scss";
export type LsysTreeWindSwayProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
windAmp?: number;
windHz?: number;
cycle?: number;
};
const ANGLE = 24;
const MAXGEN = 5;
const WIND_AMP = 30;
const WIND_HZ = 1.6;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number; d: number };
const HINT_TEXT = "F→F[+F]F[-F]F 로 완성해 둔 나무에 깊이(depth)마다 위상을 지연시킨 sin(t·1.6Hz) 바람을 더하면, 뿌리는 그대로인데 가지 끝만 크게 흔들리는 실제 나무의 바람 반응을 흉내 낼 수 있습니다";
const META_LEFT_PREFIX = "규칙 F→F[+F]F[-F]F · 바람 ";
const META_RIGHT = "canvas · 위상 지연 · requestAnimationFrame";
const FIELD_LABEL = "바람 흔들림 나무 실험판";
const RULE: Record<string, string> = { F: "F[+F]F[-F]F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysTreeWindSway({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
windAmp = WIND_AMP,
windHz = WIND_HZ,
cycle = CYCLE
}: LsysTreeWindSwayProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ bark: RGB; leaf: RGB; ink: RGB } | null>(null);
const strRef = useRef("") as Box<string>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [wind, setWind] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawWind(ctx: CanvasRenderingContext2D, str: string, ox: number, oy: number, sc: number, stepLen: number, t: number, windStrength: number): void {
const col = colRef.current;
if (!col) return;
let x = 0, y = 0, a = -Math.PI / 2, d = 0;
const stack: Stack[] = [];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const phase = t * windHz * Math.PI * 2 - d * 0.35;
const sway = Math.sin(phase) * (windAmp * Math.PI / 180) * windStrength * Math.min(1, d / 3);
const aa = a + sway;
const nx = x + Math.cos(aa) * stepLen;
const ny = y + Math.sin(aa) * stepLen;
const tcol = Math.min(1, d / 6);
const cr = col.bark[0] + (col.leaf[0] - col.bark[0]) * tcol;
const cg = col.bark[1] + (col.leaf[1] - col.bark[1]) * tcol;
const cb = col.bark[2] + (col.leaf[2] - col.bark[2]) * tcol;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.9, 3 * Math.pow(0.74, d));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (d >= 4) {
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 2.6, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.fill();
}
x = nx; y = ny; a = aa;
} else if (ch === "+") {
a += angle * Math.PI / 180;
} else if (ch === "-") {
a -= angle * Math.PI / 180;
} else if (ch === "[") {
stack.push({ x, y, a, d });
d++;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; d = st.d; }
}
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const str = strRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !str) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.5;
const ox = W * 0.5, oy = H * 0.95;
const t = loop * cycle;
const windStrength = 0.35 + 0.65 * Math.abs(Math.sin(t * windHz * Math.PI * 0.5));
setWind(Math.round(windStrength * 100));
drawWind(ctx, str, ox, oy, sc, sc / Math.pow(1.6, maxGen) * 0.5, t, windStrength);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
strRef.current = expand("F", maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.3);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.3);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, windAmp, windHz, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{wind}</b>%</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}08 · 바람에 흔들리는 나무 — 세대마다 위상이 어긋나는 진동 — 측정 누적면적 1.706% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
깊이 비례 사인파를 각도에 더하기
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
// 세대 깊이에 비례(선형)해 커지는 사인파 — 잔가지뿐 아니라 중간 가지도 함께 휘어야
// 프레임마다 바뀌는 픽셀 면적이 뿌리 근처 몇 개 선분에만 갇히지 않는다.
var sway = windAmp * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = stepLen * Math.pow(0.78, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
깊이로 굵기·명도 내기
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.globalAlpha = 0.6 + 0.4 * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(1.5, 5.2 * Math.pow(0.66, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
바람 진폭을 깊이와 무관하게 고정하면 전체가 같은 폭으로 흔들려 뻣뻣한 진동일 뿐이라, 위상에 depth * 0.8 을 더해 가지마다 어긋나게 하는 것이 정해입니다.
09대문 숲 — 브랜드 자막 위로 자라는 프랙탈 숲
씨앗 하나에 서로 다른 재쓰기 규칙 세 가지를 돌려써 나무 다섯 그루를 세대 0→4 로 동시에 키우는 대문 장면입니다. 나무마다 각도(20+(i%3)5)·크기(0.85+(i%2)0.22)·성장 지연(0.06×i)이 달라 같은 씨앗이 아니라 정말 다른 규칙으로 자라는 것처럼 보이고, 뒤편 createRadialGradient 스포트라이트가 성장이 차오를수록 밝아집니다.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<div class="ls__hero" role="application" aria-label="대문 숲 히어로 씬" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
<div class="ls__veil" aria-hidden="true"></div>
<div class="ls__copy">
<p class="ls__kicker">문자 하나가 자라면</p>
<p class="ls__title">FRACTAL<br>FOREST</p>
<p class="ls__tag" id="lsTag">규칙이 모여 숲이 됩니다</p>
</div>
</div>
<div class="ls__bar">
<span class="ls__meta">나무 <b id="lsCount">5</b>그루 · 규칙 각각 다름</span>
<span class="ls__meta ls__meta--dim">canvas · createRadialGradient</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대문 숲(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(숲 성장+바람 + 방사 그라디언트 호흡)와 JS 가 매기는
// 태그 문구 투명도(캔버스 값과 같은 호흡 위상)가 만든다. 무대는 편 공통 숲 밤 이끼.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-ink2: #0a0d07;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-ink2: #{$ls-ink2};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hero {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
display: grid; place-items: center;
}
.ls__hero:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ls__veil { position: absolute; inset: 0; pointer-events: none; }
.ls__copy {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
margin: 0; text-align: center; padding: 0 $sp-3;
}
.ls__kicker {
margin: 0;
font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
letter-spacing: .28em;
color: rgba($ls-mist, .78);
}
.ls__title {
margin: 0;
font-weight: 700; font-size: clamp(18px, 6.4vw, 27px); line-height: 1.08;
letter-spacing: .06em;
color: $ls-mist;
text-shadow: 0 2px 12px rgba($ls-ink2, .8);
}
.ls__tag {
margin: 0;
font-weight: 500; font-size: 10px; line-height: 1.3;
color: rgba($ls-mist, .8);
}
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__hero { height: 100px; }
.ls__meta--dim { display: none; }
.ls__tag { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__hero { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-ink2: #0a0d07;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hero {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
display: grid;
place-items: center;
}
.ls__hero:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ls__veil {
position: absolute;
inset: 0;
pointer-events: none;
}
.ls__copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 0;
text-align: center;
padding: 0 12px;
}
.ls__kicker {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
letter-spacing: 0.28em;
color: rgba(223, 230, 214, 0.78);
}
.ls__title {
margin: 0;
font-weight: 700;
font-size: clamp(18px, 6.4vw, 27px);
line-height: 1.08;
letter-spacing: 0.06em;
color: #dfe6d6;
text-shadow: 0 2px 12px rgba(10, 13, 7, 0.8);
}
.ls__tag {
margin: 0;
font-weight: 500;
font-size: 10px;
line-height: 1.3;
color: rgba(223, 230, 214, 0.8);
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__hero {
height: 100px;
}
.ls__meta--dim {
display: none;
}
.ls__tag {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__hero {
cursor: default !important;
}
}// 대문 숲(09) — 계산 화면 하나가 서비스 첫 화면이 되는 조립. 나무 다섯 그루가
// 저마다 다른 문자 재쓰기 규칙(F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(세대 성장 + 08 과 같은 깊이비례 바람) ② 방사 그라디언트 비네팅
// (createRadialGradient, 매 프레임 다시 그려 숨쉬듯 짙어졌다 옅어진다) ③ 타이포(kicker·title·tag).
(function () {
// ── 변형 상수 블록 ──
var MAXGEN = 4;
var CYCLE = 2.0;
var N_TREE = 5;
// ─────────────────
var root = document.getElementById('lsRoot');
var hero = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var tagEl = document.getElementById('lsTag');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var C_INK2 = tokRGB('--ls-ink2');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = hero.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 나무마다 다른 재쓰기 규칙 세 가지를 돌려쓴다 — "각기 다른 규칙으로 자란다"를 문자 그대로 구현
var RULES = [
{ 'F': 'F[+F]F[-F]' },
{ 'F': 'F[+F][-F]F' },
{ 'F': 'FF[+F][-F]' }
];
function expand(rule, gen) {
var s = 'F';
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
var TREES = [];
(function seed() {
for (var i = 0; i < N_TREE; i++) {
var rule = RULES[i % RULES.length];
TREES.push({
xFrac: (i + 0.5) / N_TREE,
str: expand(rule, MAXGEN),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % N_TREE) * 0.06
});
}
})();
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function drawTree(ctx, tr, ox, oy, sc, loop) {
var str = tr.str;
var grPhase = Math.min(Math.max(0, (loop - tr.genDelay)) / 0.5, 1);
var drawUpto = Math.floor(str.length * grPhase);
var windPhase = loop * Math.PI * 2 * 1.4 + tr.phase;
var x = 0, y = 0, a = -Math.PI / 2;
var stack = [];
var depth = 0, maxDepth = MAXGEN + 1;
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
var sway = 10 * depthT * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = tr.scale * 0.11 * Math.pow(0.72, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(0.8, 3 * Math.pow(0.68, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
x = nx; y = ny;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a, d: depth });
depth++;
a += tr.angle * Math.PI / 180;
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
a -= tr.angle * Math.PI / 180;
} else if (ch === '+') {
a += tr.angle * Math.PI / 180;
} else if (ch === '-') {
a -= tr.angle * Math.PI / 180;
}
}
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = H * 0.98;
for (var i = 0; i < TREES.length; i++) {
var tr = TREES[i];
drawTree(ctx, tr, W * tr.xFrac, H * 0.98, sc, loop);
}
// 방사 그라디언트 비네팅 — 문장이 잘 읽히도록 가운데를 밝히고 가장자리를 죽인다.
// 반지름을 호흡시켜 정지 구간에도 픽셀이 계속 갱신된다.
var breath = 0.5 + 0.5 * Math.sin(loop * Math.PI * 2 * 0.6);
var gx = W * 0.5, gy = H * 0.42;
var r0 = Math.min(W, H) * (0.08 + 0.02 * breath);
var r1 = Math.max(W, H) * 0.75;
var grad = ctx.createRadialGradient(gx, gy, r0, gx, gy, r1);
grad.addColorStop(0, rgb(C_INK, 0));
grad.addColorStop(0.55, rgb(C_INK, 0.18 + 0.06 * breath));
grad.addColorStop(1, rgb(C_INK2, 0.86));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
if (tagEl) tagEl.style.opacity = String((0.62 + 0.38 * breath).toFixed(2));
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(0.9);
});
fit();
if (reduce) {
render(0.9);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysHeroScene — vanilla 판(run/328/demo/09-lsys-hero-scene/index.html) 의 React 포트.
// 계산 화면 하나가 서비스 첫 화면이 되는 조립. 나무 다섯 그루가 저마다 다른 문자 재쓰기 규칙
// (F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(세대 성장 + 08 과 같은 깊이비례 바람) ② 방사 그라디언트 비네팅
// (createRadialGradient, 매 프레임 다시 그려 숨쉬듯 짙어졌다 옅어진다) ③ 타이포(kicker·title·tag).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysHeroScene.module.scss";
export type LsysHeroSceneProps = {
duration?: string;
easing?: string;
color?: string;
maxGen?: number;
cycle?: number;
nTree?: number;
};
const MAXGEN = 4;
const CYCLE = 2.0;
const N_TREE = 5;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number; d: number };
type Tree = { xFrac: number; str: string; angle: number; scale: number; phase: number; genDelay: number };
const KICKER_TEXT = "문자 하나가 자라면";
const TITLE_LINE1 = "FRACTAL";
const TITLE_LINE2 = "FOREST";
const TAG_TEXT = "규칙이 모여 숲이 됩니다";
const META_LEFT_PREFIX = "나무 ";
const META_LEFT_SUFFIX = "그루 · 규칙 각각 다름";
const META_RIGHT = "canvas · createRadialGradient";
const HERO_LABEL = "대문 숲 히어로 씬";
const RULES: Record<string, string>[] = [
{ F: "F[+F]F[-F]" },
{ F: "F[+F][-F]F" },
{ F: "FF[+F][-F]" }
];
function expand(rule: Record<string, string>, gen: number): string {
let s = "F";
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
function seedTrees(nTree: number, maxGen: number): Tree[] {
const trees: Tree[] = [];
for (let i = 0; i < nTree; i++) {
const rule = RULES[i % RULES.length];
trees.push({
xFrac: (i + 0.5) / nTree,
str: expand(rule, maxGen),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % nTree) * 0.06
});
}
return trees;
}
function rgb(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysHeroScene({
duration = "2s",
easing = "ease-spring",
color = "#4cd4a6",
maxGen = MAXGEN,
cycle = CYCLE,
nTree = N_TREE
}: LsysHeroSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const heroRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const tagRef = useRef<HTMLParagraphElement | null>(null);
const colRef = useRef<{ bark: RGB; leaf: RGB; ink: RGB; ink2: RGB } | null>(null);
const treesRef = useRef<Tree[]>([]) as Box<Tree[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const hero = heroRef.current;
const cv = cvRef.current;
if (!hero || !cv) return;
const r = hero.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, tr: Tree, ox: number, oy: number, sc: number, loop: number): void {
const col = colRef.current;
if (!col) return;
const str = tr.str;
const grPhase = Math.min(Math.max(0, loop - tr.genDelay) / 0.5, 1);
const drawUpto = Math.floor(str.length * grPhase);
const windPhase = loop * Math.PI * 2 * 1.4 + tr.phase;
let x = 0, y = 0, a = -Math.PI / 2;
const stack: Stack[] = [];
let depth = 0;
const maxDepth = maxGen + 1;
for (let i = 0; i < drawUpto; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const depthT = depth / maxDepth;
const sway = 10 * depthT * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
const segLen = tr.scale * 0.11 * Math.pow(0.72, depth);
const nx = x + Math.cos(a) * segLen;
const ny = y + Math.sin(a) * segLen;
const cr = col.bark[0] + (col.leaf[0] - col.bark[0]) * depthT;
const cg = col.bark[1] + (col.leaf[1] - col.bark[1]) * depthT;
const cb = col.bark[2] + (col.leaf[2] - col.bark[2]) * depthT;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.8, 3 * Math.pow(0.68, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
x = nx; y = ny;
} else if (ch === "[") {
stack.push({ x, y, a, d: depth });
depth++;
a += tr.angle * Math.PI / 180;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
a -= tr.angle * Math.PI / 180;
} else if (ch === "+") {
a += tr.angle * Math.PI / 180;
} else if (ch === "-") {
a -= tr.angle * Math.PI / 180;
}
}
}
function render(loop: number): void {
const cv = cvRef.current;
const col = colRef.current;
const TREES = treesRef.current;
const W = wRef.current, H = hRef.current;
if (!cv || !col) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctx.fillStyle = rgb(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = H * 0.98;
for (let i = 0; i < TREES.length; i++) {
const tr = TREES[i];
drawTree(ctx, tr, W * tr.xFrac, H * 0.98, sc, loop);
}
const breath = 0.5 + 0.5 * Math.sin(loop * Math.PI * 2 * 0.6);
const gx = W * 0.5, gy = H * 0.42;
const r0 = Math.min(W, H) * (0.08 + 0.02 * breath);
const r1 = Math.max(W, H) * 0.75;
const grad = ctx.createRadialGradient(gx, gy, r0, gx, gy, r1);
grad.addColorStop(0, rgb(col.ink, 0));
grad.addColorStop(0.55, rgb(col.ink, 0.18 + 0.06 * breath));
grad.addColorStop(1, rgb(col.ink2, 0.86));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
const tagEl = tagRef.current;
if (tagEl) tagEl.style.opacity = (0.62 + 0.38 * breath).toFixed(2);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
render(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink"),
ink2: tokRGB(root, "--ls-ink2")
};
treesRef.current = seedTrees(nTree, maxGen);
fit();
const reduce = reduced();
function onResize(): void {
fit();
render(0.9);
}
window.addEventListener("resize", onResize);
if (reduce) {
render(0.9);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxGen, cycle, nTree]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<div className={styles.hero} ref={heroRef} role="application" aria-label={HERO_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<div className={styles.veil} aria-hidden="true"></div>
<div className={styles.copy}>
<p className={styles.kicker}>{KICKER_TEXT}</p>
<p className={styles.title}>{TITLE_LINE1}<br />{TITLE_LINE2}</p>
<p className={styles.tag} ref={tagRef}>{TAG_TEXT}</p>
</div>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{nTree}</b>{META_LEFT_SUFFIX}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}09 · 대문 숲 — 브랜드 자막 위로 자라는 프랙탈 숲 — 측정 누적면적 11.5% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
규칙 세 가지를 돌려쓰는 씨앗 뿌리기
// 나무마다 다른 재쓰기 규칙 세 가지를 돌려쓴다 — "각기 다른 규칙으로 자란다"를 문자 그대로 구현
var RULES = [
{ 'F': 'F[+F]F[-F]' },
{ 'F': 'F[+F][-F]F' },
{ 'F': 'FF[+F][-F]' }
];
function expand(rule, gen) {
var s = 'F';
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
나무마다 다른 각도·크기·지연
var TREES = [];
(function seed() {
for (var i = 0; i < N_TREE; i++) {
var rule = RULES[i % RULES.length];
TREES.push({
xFrac: (i + 0.5) / N_TREE,
str: expand(rule, MAXGEN),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % N_TREE) * 0.06
});
}
})();
규칙을 하나로 통일하면 다섯 그루가 복제된 나무로 보이니, 세 가지를 돌려쓰는 것이 이 장면의 전제입니다.
expand 를 렌더 루프 안에서 부르면 함정이 시작된다
세대별 문자열을 매 프레임 다시 전개하면 폭주한다
가장 자주 만나는 깨짐은 expand 를 렌더 루프 안에서 부르는 것입니다. 규칙에 따라 문자열이 세대마다 3배~2배씩 늘어 06 용 곡선의 세대 11 이면 한 프레임에 수십만 글자를 다시 써야 하니 프레임이 끊깁니다. 세대별 문자열을 미리 배열에 저장해 두고 렌더는 순회만 하는 것이 정해인데, 세대 경계의 진행률 보간으로 낮은 세대의 짧은 문자열에서 다음 세대의 긴 문자열로 점을 섞어 넘어가야 부드럽습니다. 08 의 바람 진폭을 깊이와 무관하게 고정하면 전체가 같은 폭으로 흔들려 바람이 아니라 진동이 되고, 04 의 두 문자 규칙을 하나로 합치면 서로를 다시 쓰는 구조가 사라져 삼각형 빈틈이 메워지지 않습니다. 이 아홉 장면은 규칙 전개부터 거북 순회까지 통째로 index.html 하나씩으로 들어 있는데 그 원본을 그대로 묶은 암호 zip 의 비밀번호는 z8hwyvrw 이고 풀면 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.
왜 몇 글자의 규칙이 나무가 되는가 — 재쓰기와 자기유사성
L시스템의 나무는 그린 것이 아니라 다시 쓴 것입니다. 규칙 F→F[+F][-F]F 는 F 하나를 만나면 F[+F][-F]F 로 바꾸라는 뜻인데, 세대를 거듭할 때마다 이 바꾸기가 결과 문자열 전체에 다시 적용되어 F 가 포함된 조각마다 똑같은 구조가 한 단계 작게 복제됩니다. 거북이가 그 문자열을 처음부터 걸으면 [ 를 만날 때마다 현재 위치와 방향을 스택에 저장했다가 ] 에서 되돌아오므로, 한 갈래를 다 그리고 갈라지기 직전 자리로 돌아와 반대 갈래를 그립니다. 곡선 호출은 한 줄도 없는데 나뭇가지가 나오는 것은 결과 문자열 전체가 거북의 걸음 순서이기 때문이고, 가지마다 같은 규칙의 복제가 들어 있으니 전체가 부분을 닮은 자기유사 모양이 됩니다. 세대를 얼마나 반복할지와 각도를 몇 도로 꺾을지 두 값만 바꿔도 침엽수가 활엽수가 되고 눈송이가 해안선이 되는 것도 이 때문입니다.
곡선을 직접 그리는 순간 재쓰기가 사라진다
여기에 arc 나 2차 곡선 호출을 한 줄 섞는 순간 화면은 여전히 예뻐 보이지만 L시스템이 아닙니다. 이 장면의 곡선은 문자열 재쓰기가 만든 걸음 순서의 결과인데 곡선 함수가 그 근거를 대신 그려버리면 남은 선분들의 규칙성도 함께 죽습니다. 그래서 아홉 장면의 구조선을 전부 moveTo·lineTo 만으로 그리고, arc·quadraticCurveTo 는 씨앗 점·잎눈 같은 장식용 점 강조에만 썼습니다.
이런 화면에서 쓴다 — 브랜드 인트로와 로딩 화면
아홉 판 중 가장 먼저 붙는 자리는 09 대문 숲 같은 히어로 섹션입니다. 로고나 브랜드 문구 위로 나무가 자라 올라오는 동안 방문자는 몇 초를 자연스럽게 붙잡히고, 그 사이 뒤 페이지가 로드됩니다. 01 재귀 가지처럼 가장 단순한 판은 로딩 스피너 대신 쓰기 좋은데, 규칙 두어 줄만 바꿔도 나무 모양이 달라지기 때문에 데모를 그대로 복붙해도 다른 사이트처럼 보이지 않습니다.
07 매개변수 식물은 반대로 상호작용이 있는 자리에 어울립니다. 슬라이더로 각도나 세대를 바꾸게 열어 두면 방문자가 직접 식물을 키우는 미니 도구가 되는데, 원예·식물 커머스 사이트의 제품 소개 옆이나 교육용 코딩 페이지의 실습 예제로 붙이면 같은 코드가 완전히 다른 역할을 합니다.
세 변형 값과 reduced-motion — 나무가 멈추는 자리
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 가지 각도 | 01 각도 25° → 45° | 가지가 넓게 벌어지며 침엽수에서 활엽수로 옷을 갈아입는다 |
| 재귀 깊이 | 07 깊이 4 → 8 | 세대가 두 배로 늘면 잎이 화면을 채우고 계산량은 2의 거듭제곱으로 뛴다 |
| 축소 비율 | 08 길이 비율 0.72 → 0.85 | 가지가 덜 줄어들수록 숲이 빽빽해지고 흔들림이 무겁게 느려진다 |
동작을 줄인 사용자에게 주는 장면
모든 판은 시작할 때 prefers-reduced-motion: reduce 를 검사해 켜져 있으면 애니메이션 루프를 돌리지 않고 고정 장면 하나를 그린 뒤 루트 요소에 .is-still 클래스를 붙입니다. 여덟 판은 최대 세대까지 다 자란 모습을 고정하고 01 만 한 세대 못 미친 가지를 고정하니, 정지 사용자에게도 빈 캔버스가 아니라 자란 나무가 남습니다.
화면 밖 읽는 사람과 화면 낭독
아홉 판 모두 자동 재생이고 사람이 알 수 있는 aria-label(재귀 가지 실험판 류)과 tabindex 를 가지며, 규칙·각도·세대 범위 같은 측정 메타가 화면 아래 한 줄로 항상 표시됩니다.
L 시스템 나무를 처음 붙일 때 자주 나오는 질문
프랙탈 나무 그리기를 이 데모로 처음 시작해도 따라할 수 있나요?
핵심은 규칙 객체 하나와 문자열을 한 글자씩 치환하는 expand 함수, 그리고 F 전진·+/- 회전·[ ] 스택의 거북 해석 세 조각인데 01 판의 스무 줄 남짓이 그 전부입니다. zip 안에는 아홉 장면을 같은 조건으로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작하고, 그다음은 각도와 세대 수를 하나씩 바꿔 보는 순서가 가장 빠릅니다.
프랙탈 구조 나무처럼 내 규칙을 만들어 다른 나무를 자라나게 할 수 있나요?
07 이 정확히 그 구조입니다. RULE 객체의 치환 문자열 몇 글자와 대괄호 위치를 바꾸면 잎이 침엽으로 바뀌고 갈래 수가 달라지는데, 각도와 길이 비율까지 움직이면 같은 문법으로 완전히 다른 식물이 자랍니다. 09 처럼 규칙 두세 개를 나무마다 돌려쓰면 한 장면에 숲이 들어섭니다.
피타고라스 나무 프랙탈의 남은 넓이처럼 수치도 화면에서 확인할 수 있나요?
05 카펫이 세대마다 getImageData 로 실제 채워진 픽셀을 세어 남은 넓이 % 를 화면에 그대로 띄웁니다. 세대가 하나 늘 때마다 남은 넓이는 8/9 씩 줄어 세대 4 에서 약 62% 인데, 이는 장식 수치가 아니라 캔버스에서 방금 읽은 실측값이라 표시된 숫자와 손계산이 맞아떨어집니다.









