L-System Fractal Tree Generator: 9 Demos
One F steps forward, one + turns. Reread those few letters enough times and branches grow on their own — the whole recursion engine, copy-paste.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Recursive branch — the minimal two-way tree
- 02 Pythagoras tree — squares upon squares
- 03 Koch snowflake — lifting edges into an infinite coast
- 04 Sierpinski triangle — triangles hiding triangles
- 05 Sierpinski carpet — holes drilling in cell by cell
- 06 Dragon curve — a folding rule that grows a paper dinosaur
- 07 Parametric plant — leaves grown from an F+-[] string
- 08 Tree in wind — a sway whose phase drifts per generation
- 09 Hero grove — a fractal forest growing over the brand line
Every scene below runs the same two-stage pipeline: a string gets rewritten character by character for a fixed number of generations (an axiom like F or A expanded through a rule map such as F → F[+F][-F]F), then a turtle walks that finished string once — F steps forward, +/- turn by a fixed angle, [/] push and pop position and heading onto a stack so a branch can fork and the turtle can return to it. No arc or quadraticCurveTo ever draws a structural line; every branch, coastline, and fold is a straight lineTo segment, and the curve you see is only the accumulation of many short segments at a fixed angle. Zero libraries, zero WebGL, zero CDN — the zip behind this post carries all nine as vanilla HTML plus a React port of each, unlocked with the password in this post.
From a Single F's Recursion to Curves and Parameters: How the L-System Order Spreads
01 is the pipeline at its smallest: one rule, F → F[+F][-F]F, at a 26° angle carried to generation 6, and a two-way branching tree appears — the item every other rule in this set builds on. 02 swaps the string rewrite for direct geometric recursion (two squares resting on a square's hypotenuse, shrinking by 0.707 each generation) to show that self-similarity does not require turtle graphics at all. 03 and 04 are the classic pair: Koch's F → F+F--F+F folds a triangle's three sides into an infinite coastline, while Sierpinski's triangle comes from two characters, A and B, rewriting each other. 05 changes medium entirely — it rewrites a grid cell instead of a line segment, and reads the result back with getImageData instead of tweening a number. 06 pushes the same idea to generation 11, folding a strip in half over and over without ever crossing itself. 07 turns the axiom into a parameter lab, where two numbers reshape a whole plant species. 08 and 09 add a second layer of computation on top of a fixed string: 08 drives the turn angle with a depth-scaled sine wave, and 09 grows five trees under three cycling rules toward a spotlighted tagline. Seven of the nine transitioned on 23 of 24 sampled frames; 05's grid rewrite and 07's parametric plant both stall on one sampled frame (22/24) — expected for 05, whose cells update only when a generation completes, but incidental for 07, which otherwise grows continuously.
| # | Item | Rule | Angle / gen | Painted area | Peak frame | Avg intensity |
|---|---|---|---|---|---|---|
| 01 | Recursive branch | F → F[+F][-F]F | 26° · 0→6 | 2.123% | 1.581% | 82.9 |
| 02 | Pythagoras tree | square on hypotenuse | shrink 0.707 · 0→8 | 1.792% | 1.481% | 104.6 |
| 03 | Koch snowflake | F → F+F--F+F | 60° · 0→4 | 8.315% | 3.773% | 45.0 |
| 04 | Sierpinski triangle | A→B-A-B, B→A+B+A | 60° · 0→6 | 5.87% | 4.984% | 83.7 |
| 05 | Sierpinski carpet | 1 → 111101111 | grid · 0→4 | 16.167% | 14.143% | 141.3 |
| 06 | Dragon curve | X→X+YF+, Y→-FX-Y | 90° · 0→11 | 5.192% | 5.162% | 88.4 |
| 07 | Parametric plant | X→F+[[X]-X]-F[-FX]+X | 25°±6° · 0→5 | 6.449% | 4.406% | 89.2 |
| 08 | Wind-sway tree | F → F[+F]F[-F] | 24° · 0→5 | 1.706% | 0.904% | 70.2 |
| 09 | Hero grove | 3 rules cycled | 0→4 · 5 trees | 11.5% | 9.198% | 63.6 |
Painted area sitting low across most rows is the nature of this set, not a defect: only 05's carpet fills whole grid cells with fillRect, so its 16.167% maps straight onto pixel occupancy, and 09's five overlapping trees push the total to 11.5%. The other seven items draw nothing but 1–5px lineTo strokes, so a branch or a coastline barely grazing the canvas is exactly what a turtle-graphics tree looks like once it is measured instead of eyeballed.
Code per item
Each fence below is copied straight out of that item's own index.html — same variable names, same rule strings — with only the inline comments translated. The full nine vanilla files plus nine React ports ship in the zip.
01Recursive branch — the minimal two-way tree
A trunk drawn with F, rewritten into F[+F][-F]F for six generations at a 26° angle, is the entire tree: no other rule in this set does less to earn a branch. Measured against the canvas, it paints 2.123% of the field (1.581% peak frame), average intensity 82.9, transitioning on 23 of 24 sampled frames.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>One F is a step forward, one + is a turn. Rewriting F → F[+F][-F]F for six generations alone grows the branches — pause it one generation at a time to see where the rule forks</span>
</p>
<div class="ls__field" role="application" aria-label="Recursive branch lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule F[+F][-F]F · angle 26° · gen 0 → 6</span>
<span class="ls__meta ls__meta--dim">canvas · string rewriting · 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-시스템 최소 Trees. 줄기 F 를 규칙 F[+F][-F]F 로 다시 쓰고
// 거북이(F=전진, +=반시계 회전, -=시계 회전, [=상태 저장, ]=복귀)로 선분을 적분한다.
// [ ] 스택이 없으면 두 번째 갈래가 첫 갈래 끝에서 시작해 나무가 아니라 지그재그가 된다.
// 움직임 = gen 성장 애니메이션. 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 를 규칙으로 ngen 전개
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 gen 0→6 성장, 0.72~1.0 완전 상태 유지+잎 호흡
var grPhase = Math.min(loop / 0.72, 1);
var genF = grPhase * 6; // 실수 gen — gen 사이를 선형으로 지나며 자란다
var gen = Math.min(6, Math.floor(genF));
var frac = genF - gen; // 현재 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);
// 떠다니는 씨앗 — 성장 초기(gen 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();
// 성장 — 이전 gen(전체)를 밝게 깔고 현재 gen 마지막 분기를 이어 그린다
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 는 스택 깊이 — gen 번호를 그대로 색·굵기 감쇠 파라미터로 쓴다(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 — 완성 Trees 한 장만 고정
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 · Recursive branch — the minimal two-way tree — Measured area 2.123% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Rewriting F six generations before a single pixel is drawn
// L-system string rewriting -- expand axiom F through the rule for n generations
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;
}
02Pythagoras tree — squares upon squares
Resting two squares on a square's hypotenuse and recursing that rule through generation 8 grows a barrel-shaped grove of roughly 250 squares, each side 0.707 (root 2 over 2) shorter than its parent. Once fully grown it paints only 1.792% of the field (1.481% peak, intensity 104.6) but cycles color from summer green through autumn amber and back, so the low-area reading never means the screen sits still.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Stack two squares on a square's hypotenuse and repeat the rule through generations 0 → 1 → 2: a pipe-like grove grows, area shrinks as edges multiply, and the fractal dimension edges toward 2</span>
</p>
<div class="ls__field" role="application" aria-label="Pythagoras tree lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">side s · shrink 0.707 · gen 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;
}
}// 피타고라스 Trees(02) — 정사각형 위에 직각삼각형(빗변을 아래 변으로)을 얹고
// 그 빗변에 다시 정사각형 둘을 붙이는 재귀. L-시스템 문자열로도 쓸 수 있지만(0 → 1[1]1)
// 여기선 재귀 함수가 문자열 전개와 같은 나무를 만든다는 대응을 보여준다.
// 움직임 = gen 성장(0→8) + 자란 뒤 계절 색 순환(초록 여름 → 호박 가을 → 다시 봄).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var SIDE0 = 0.21; // 첫 정사각형 한 변(무대 세로 배율) — 피사체를 키워 누적면적을 확보
var CYCLE = 2.0; // 성장+계절 주기(초)
var MAXGEN = 8; // 최대 gen — 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 gen 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 = 남은 gen
function drawTree(ctx, x, y, s, gen, genF, seasonT) {
if (s < 0.9 || gen > genF) return;
var thisF = Math.min(1, genF - gen); // 현재 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 · Pythagoras tree — squares upon squares — Measured area 1.792% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One render loop scheduling growth, season, and a resting sway
function render(loop) {
var ctx = cv.getContext('2d');
// growth schedule: 0-0.55 generations 0->8 growing, 0.55-1.0 full grove + season color cycle
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); // autumn -> spring -> autumn round trip
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;
// a very faint sway after growth completes -- root fixed, keeps grove edge pixels
// refreshing even outside the season transition (a micro-vibration, unlike item 08's wide-amplitude wind)
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();
}
03Koch snowflake — lifting edges into an infinite coast
Starting from a triangle's three sides (axiom F--F--F) and rewriting every F into F+F--F+F at 60° for four generations lengthens the coastline every pass while the enclosed area barely grows — the textbook demonstration of fractal dimension. It paints 8.315% of the canvas (3.773% peak, intensity 45.0), the third-highest area in the set after 05 and 09.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Repeat the one-line rewrite F → F+F--F+F and each triangle side folds finer, lengthening the snowflake coastline — perimeter grows while the inner area barely changes, revealing the fractal dimension</span>
</p>
<div class="ls__field" role="application" aria-label="Koch snowflake lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule F→F+F--F+F · angle 60° · gen <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · string rewriting · 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 로 되풀이해 다시 쓰면
// 가운데를 들어올린 산 모양이 매번 새로 끼어들어 해안선이 무한히 길어진다(둘레 → ∞, 넓이 → 유한).
// 움직임 = gen 크로스페이드 성장(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));
}
// 최종 gen 기준 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 : gen 크로스페이드 성장. 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) {
// 둘레를 도는 빛 — 최종 gen 점 목록을 따라 위치를 진행시킨다(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 · Koch snowflake — lifting edges into an infinite coast — Measured area 8.315% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The one-line rewrite behind the whole coastline
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;
}
04Sierpinski triangle — triangles hiding triangles
Two characters that rewrite each other — A → B-A-B, B → A+B+A — unfolded with a 60° turtle walk trace a one-stroke arrowhead curve, and by generation 6 that single unbroken line fills exactly the gaps of a Sierpinski triangle. It measures 5.87% painted area with a 4.984% peak frame, the closest peak-to-total ratio in the set, meaning most of its ink lands in a short burst near the end of growth.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Unfolding the two characters A→B-A-B, B→A+B+A with a 60° turtle walk makes a one-stroke arrowhead curve — the more generations you add, the more exactly that curve fills the gaps of the Sierpinski triangle</span>
</p>
<div class="ls__field" role="application" aria-label="Sierpinski triangle lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule A→B-A-B · angle 60° · gen <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · string rewriting · 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 두 기호가 서로를 끼워 넣으며
// 자라는 한붓그리기 곡선이 세대가 늘수록 삼각형 가스켓의 구멍을 정확히 남긴다.
// 움직임 = gen 크로스페이드 성장(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 · Sierpinski triangle — triangles hiding triangles — Measured area 5.87% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The turtle walk that a two-character mutual rewrite produces
// turtle interpretation -- A and B both step forward one unit, +/- turn 60 degrees; builds an open point list
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;
}
05Sierpinski carpet — holes drilling in cell by cell
Every filled cell (1) rewrites into nine characters arranged 3×3 with only the center left as a hole (0) — 1 → 111101111 — and across four generations that drills a square full of holes like moth-eaten cloth, while getImageData reads the actual non-background pixels back for the "remaining area" readout instead of tweening a number. It paints the most of the set, 16.167% (14.143% peak) and the brightest average intensity, 141.3, and is one of two items in this set (with 07) that stall on a single sampled frame (22 of 24) — expected here since its display only refreshes at each generation boundary.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Rewrite one filled cell (1) as nine characters with only the center as a hole (0), repeated each generation — the remaining area shrinks by 8/9 each time, and getImageData counts it for real</span>
</p>
<div class="ls__field" role="application" aria-label="Sierpinski carpet lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">gen <b id="lsGen">0</b> · area <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 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 : gen 성장(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 · Sierpinski carpet — holes drilling in cell by cell — Measured area 16.167% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Interpreting the rewritten string as a filled grid, not a turtle walk
// interpret the generation-gen string as a 3^gen x 3^gen grid, fillRect only the filled cells
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));
}
}
06Dragon curve — a folding rule that grows a paper dinosaur
Two rules, X → X+YF+ and Y → -FX-Y, applied to the single-line axiom FX at a 90° turn produce the fold pattern of a strip of paper folded in half again and again — carried all the way to generation 11 so the folding density fills in completely, without the curve ever crossing itself. It paints 5.192% of the canvas with a 5.162% peak frame — the two numbers sit almost equal, meaning nearly all of its ink lands in that single final frame as the deepest folds resolve.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Repeating the two rules X→X+YF+, Y→-FX-Y on the single line FX grows a dragon curve that folds as densely as a strip of paper folded in half again and again — without ever overlapping itself</span>
</p>
<div class="ls__field" role="application" aria-label="Dragon curve lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule X→X+YF+ · angle 90° · gen <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · string rewriting · 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도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = gen 크로스페이드 성장(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 · Dragon curve — a folding rule that grows a paper dinosaur — Measured area 5.192% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The two mutually-rewriting rules behind the fold
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;
}
07Parametric plant — leaves grown from an F+-[] string
Adding F → FF to a single rule, X → F+[[X]-X]-F[-FX]+X, and letting generation and angle drive it produces a houseplant leaf; once fully grown the base angle of 25° wobbles inside a ±6° band via a sine wave, showing in real time that the same grammar with a different parameter reads as a different plant. It paints 6.449% of the canvas (4.406% peak, intensity 89.2).
![Parametric plant — leaves grown from an F+-[] string](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>Adding F→FF to the one line X→F+[[X]-X]-F[-FX]+X and changing just two numbers — angle and ratio — grows entirely different plants, from ferns to shrubs, out of the same grammar: the parameters decide the species</span>
</p>
<div class="ls__field" role="application" aria-label="Parametric plant lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule X→F+[[X]-X]-F[-FX]+X · angle <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).
// 대괄호 [ ] 로 위치·방향을 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = gen 크로스페이드 성장(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 · Parametric plant — leaves grown from an F+-[] string — Measured area 6.449% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
The single rule pair the whole leaf comes from
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;
}
08Wind-sway tree — a sway whose phase drifts per generation
The rule F → F[+F]F[-F] at 24° and generation 0→5 is left completely untouched; instead every turtle turn adds a sine term whose amplitude scales with branch depth (peak amplitude 30°, frequency 1.6 cycles per loop), while branch length shrinks by 0.78 to the power of depth. The root barely moves while only the twig tips whip hard into the wind, and the item paints the least of the set — 1.706% (0.904% peak, intensity 70.2) — because it is exactly what a thin-stroke branching tree should measure at rest.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Leave the string F→F[+F]F[-F] untouched, and just add a sine wave — growing with each generation's depth — to the turtle's turning angle at every step: the root barely moves while only the twig tips whip into wind</span>
</p>
<div class="ls__field" role="application" aria-label="Wind-sway tree lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule F→F[+F]F[-F] · wind strength <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;
}
}// 바람 흔들림 Trees(08) — 문자열 자체(F→F[+F]F[-F])는 고정. 거북이 걸을 때 방향각에
// "깊이(gen)에 비례하는 사인파"를 더해서, 같은 문자열이 매 프레임 다른 모양으로 보인다.
// 뿌리는 위상이 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;
// gen 깊이에 비례(선형)해 커지는 사인파 — 잔가지뿐 아니라 중간 가지도 함께 휘어야
// 프레임마다 바뀌는 픽셀 면적이 뿌리 근처 몇 개 선분에만 갇히지 않는다.
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 · Tree in wind — a sway whose phase drifts per generation — Measured area 1.706% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The same fixed string every wind-sway frame reuses
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;
}
09Hero grove — a fractal forest growing over the brand line
Five seeds grow simultaneously, each cycled from one of three rules (F[+F]F[-F], F[+F][-F]F, FF[+F][-F]) through generations 0→4, with its own angle, scale, and growth delay so the scene reads as genuinely different rules rather than one seed copy-pasted five times. A radial-gradient spotlight brightens behind them as growth fills up, and the scene paints 11.5% of the canvas (9.198% peak, intensity 63.6), the second-highest area in the set.

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<div class="ls__hero" role="application" aria-label="Hero grove scene" 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">When one character grows</p>
<p class="ls__title">FRACTAL<br>FOREST</p>
<p class="ls__tag" id="lsTag">Rules gather into a forest</p>
</div>
</div>
<div class="ls__bar">
<span class="ls__meta">Trees <b id="lsCount">5</b> · each a different rule</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) — 계산 화면 하나가 서비스 첫 화면이 되는 조립. Trees 다섯 그루가
// 저마다 다른 문자 재쓰기 규칙(F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(gen 성장 + 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 · Hero grove — a fractal forest growing over the brand line — Measured area 11.5% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Three rules, cycled by index, so no two neighboring trees repeat
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;
}
Why a few letters grow a tree — rewriting and self-similarity
The string is the entire walking order
Once expand() has run its generations, the resulting string is not a description of a tree — it is a literal, ordered list of turtle instructions. A fractal tree generator built this way never stores coordinates for a branch; it stores one character (F, +, -, [, ]) per step and lets the walk assemble the geometry as a side effect of reading the string left to right. That is why 01's six-generation string and 06's eleven-generation string share the same expand() shape (a rule map applied once per generation) despite drawing a tree and a dragon curve — the rule table, not the walker, decides what the shape becomes.
Self-similarity is a rewrite applied to its own output
Every rule map in this set replaces a character with a string that can contain that same character again (F maps to a string that still holds an F inside it), so applying the rule to its own output makes each new generation a scaled copy of the whole shape sitting inside itself — a branch is a smaller tree, a triangle is three smaller triangles, a grid cell is nine smaller cells. This is also why generation count is the one dial every item exposes: 02's 8 generations and 05's 4 generations are not arbitrary, they are how many times self-similarity gets to repeat before the browser would spend more time computing invisible sub-pixel detail than drawing anything new.
An l system fractal tree is math you can watch happen
A search for l system fractal tree usually turns up a still picture; every item here instead grows from generation 0 to its maximum live, because a finished frame with no growth removes the one thing that makes this an L-system demo instead of a picture of one. Even after growth completes, tips keep breathing or swaying — 01's leaf pulse, 08's wind — because a fully-grown, perfectly still tree is indistinguishable from a raster export of the same shape.
Where this actually gets used — a landing page hero and a loading screen
The first place these nine scenes tend to land is a hero section like item 09's grove, growing over a brand line while the rest of the page loads behind it — a few seconds of motion that reads as intentional rather than a stall. Item 01's minimal two-way branch works just as well swapped in for a plain loading spinner: change two rule characters and the same code reads as a completely different brand without touching the turtle logic underneath.
Item 07 fits the opposite kind of screen, one with an actual control on it. Expose its angle or generation depth to a slider and the scene becomes a small toy the visitor drives directly — next to a plant-shop product photo, or as the live example on a coding-tutorial page, the same twenty lines of rule-and-turtle code plays a different role each time.
Branch angle, depth, and reduced-motion — three dials that change the read
| Name | Change | Feel |
|---|---|---|
| Branch angle | item 01 angle 25° → 45° | branches spread wide and the tree's silhouette shifts from conifer to broadleaf |
| Recursion depth | item 07 depth 4 → 8 | doubling the generations fills the screen with leaves while compute cost jumps by a power of two |
| Shrink ratio | item 08 length ratio 0.72 → 0.85 | the less each branch shrinks, the denser the grove becomes and the sway feels heavier and slower |
Reduced motion freezes each scene at a grown state
Every scene checks prefers-reduced-motion before scheduling its requestAnimationFrame loop; when the preference is set, the loop never starts and the scene renders once instead, then the root element takes an is-still class. Eight of the nine land on their true maximum generation — only 01 stops one generation short: its reduced-motion frame renders at loop 0.6, but 01's own growth schedule only reaches full generation 6 at loop 0.72, so the fixed frame lands at generation 5 of 6. Even one generation short, a two-way branching tree is what's on screen — nothing here regresses to a blank canvas for users who have motion turned off.
One measurement line, one aria-label, per canvas
Each item's canvas host carries a human-readable aria-label (a fractal tree maker like this one calls item 01's stage a "recursive branch lab") and a tabindex, and a line under the canvas always shows the rule, angle, and generation range currently rendering — so the same fact a screen reader announces is also printed as visible text.
Rewriting the axiom for a different shape is the fastest way to break it
Rewriting the axiom differently starves the shape of self-similarity
Every rule map here only makes sense against its own axiom — 03's F--F--F axiom is an equilateral triangle's three sides at 120° apart, and rewriting a different starting string with the same rule folds the wrong shape entirely. Change 04's axiom from A to F and the two-character mutual rewrite that fills the Sierpinski gaps never triggers, because the rule map only knows what to do with A and B.
Re-expanding the string every frame instead of once per generation
expand() triples or worse the string length per generation, and by 06's generation 11 the string is long enough that calling expand() inside requestAnimationFrame — at 60 calls a second — would turn a one-time cost into a per-frame one. Every item here precomputes the full array of generation strings once, before the loop starts, and the frame loop only ever indexes into that array.
Fixing 05's remaining-area number without reading real pixels
The temptation is to tween the displayed percentage toward a precomputed target ((8/9)^gen) instead of measuring it. That number would be correct in theory but would stop being a measurement — 05 calls getImageData on the actual canvas region and counts non-background pixels directly, which is also the reason its display only refreshes once per generation rather than every frame: the count does not change until the next rewrite lands. Take the whole set home as a fractal tree generator online you can retype from these fences, or grab the zip of nine vanilla files plus nine React ports that opens with z8hwyvrw and keep its one law: straight segments only, curves by accumulation.
Giving 08's wind the same amplitude at every depth
If the sine sway in item 08 were applied uniformly regardless of branch depth, the whole tree would rock as one stiff unit instead of swaying like a real one. The amplitude term scales with depthT * depthT specifically so the trunk barely deflects while only the outermost twigs whip near the full 30° — a squared depth ratio, not a linear one, is what keeps the base still.
Questions before you paste this fractal tree code
Is this a fractal tree generator example I can paste into a plain HTML file
Yes — each of the nine items is one self-contained index.html with no external script, CDN, or image, so copying its <canvas> markup and script tag into any page runs the scene as-is. The zip behind this post adds a React port of every item for component-based projects, using the same rule strings and turtle logic shown above.
What is a fractal tree, mechanically, in this set
It is the output of walking a string that a rule map has rewritten for a fixed number of generations — nothing about the shape is drawn directly. Item 01 rewrites F into F[+F][-F]F six times and walks the result with a turtle; item 02 recurses geometrically instead of rewriting a string, which is why its "generation" still means the same thing (one more self-similar layer) even without an expand() call.
Why does my copy only show a flat line instead of a branching tree
Almost always the [/] stack push and pop is missing or mismatched — without it, the second branch off any fork starts from wherever the first branch ended, not from the fork point, so a tree collapses into a zigzag. Check that every [ in the rule string pushes the current x, y, and angle, and every ] pops the same three values back, in that order.
Sources: MDN — CanvasRenderingContext2D.lineTo() · MDN — CanvasRenderingContext2D.save() · MDN — CanvasRenderingContext2D.restore() · MDN — Window.requestAnimationFrame() · Wikipedia — L-system · this set also sits alongside a canvas scene where thread hops between pins by rule and a crystal that grows one random-walking particle at a time — both compute a shape from a repeated rule rather than drawing it directly.









