シェルピンスキーの三角形など9種 — 文字が枝を生むキャンバス
Fは前進、+は回転。この数文字を読み直すだけで枝がひとりでに伸びます。再帰エンジン一式をコピペで持ち帰れます。シェルピンスキーの三角形も含む9つの図形はすべて、この文字列書き換え(Lシステム)とタートルグラフィックの組み合わせで一枚のキャンバスに計算して描いたものです。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 再帰枝 — 2又に分かれる最小の木
- 02 ピタゴラスの木 — 正方形の上に正方形
- 03 コッホ雪片 — 辺を持ち上げて無限の海岸線
- 04 シェルピンスキーの三角形 — 三角形に潜む三角形
- 05 シェルピンスキーのカーペット — マスを抜って掘る穴
- 06 ドラゴン曲線 — 折り紙の規則が育てる紙の恐竜
- 07 パラメータ植物 — F+-[] 文字列が育つ葉
- 08 風に揺れる木 — 世代ごとに位相がずれる揺れ
- 09 表紙の森 — ブランド行の上に育つフラクタルの森
デモ
デモは9つとも同じ構成です。ライブラリ・WebGL・外部CDNを使わず、index.html 1枚と style.css だけで完結するので、コピーしてそのままブラウザで開けば同じ場面が動きます。測定では9項目のうち7項目(01・02・03・04・06・08・09)が24コマ中23コマで世代が切り替わり(23/23)、05カーペットと07植物だけが22コマでの切り替わり(22/23)でした。世代の境目で進行度が1コマ止まる場所があるのは、文字列を世代ごとにしか書き換えていない証拠で正常です。累積面積は1.706%(08風に揺れる木)から16.167%(05カーペット)まで幅があります。05だけが fillRect でマス目を塗りつぶす方式で塗られた面積そのものが数値になり、残り8項目は幅1〜5px程度の lineTo 線が枝や海岸線を1本ずつなぞる方式だからです。背景を塗る絵ではなく、線が世代ごとに育つ絵、というのがこの回の性質です。
文字Fひとつの再帰から曲線・パラメータまで広がったL-systemの順序
9つの並びは、規則の複雑さと図形の知名度の両方に沿っています。01の再帰枝は F 一文字と角括弧2つだけで木になる最小実装、02のピタゴラスの木・03の雪片の海岸線・04のシェルピンスキーの三角形は教科書にも載る古典フラクタルで、規則を1つ増やすごとに図形の性格が変わることを見せます。05のカーペットで初めて「トゥイーンの数値ではなく実測」に踏み込み、06のドラゴン曲線は2文字が互いを書き換え合う応用、07の植物は角度を連続的に揺らして生成文法を体感させます。08は世代番号そのものを振動のパラメータに変え、最後の09は5本の木が3種類の規則を使い分けて表紙の一場面に組み上がります。単純な規則から複合的な場面へ、という順番です。
項目別コード
01再帰枝 — 2又に分かれる最小の木
幹を描き、短くした自分自身を2回呼ぶだけで木が完成します。角度26度・縮小比率0.74で世代0→6まで育て、育ち切った後は枝先がサイン波でわずかに呼吸します。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>F 1つは前進、+ 1つは回転。F → F[+F][-F]F を6世代だけ書き換えれば枝は自然に伸びます。世代ごとに止めて見ると、規則がどこで枝分かれするかが分かります</span>
</p>
<div class="ls__field" role="application" aria-label="再帰的な枝分かれラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 F[+F][-F]F · 角度26° · 世代0→6</span>
<span class="ls__meta ls__meta--dim">canvas · 文字列書き換え · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 재귀 가지(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자열 재쓰기 세대 성장 + 잎 호흡 밝기 진동)가 만든다.
// 무대는 숲 밤 이끼. 파스텔 팔레트를 --ls-* 로 편 공통(01~09) 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
// ── 편 공통 숲 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$ls-stage: #1a2116; // 숲 밤 이끼 — 저채도 짙은 녹색 무대 (파스텔 검사 무관: 저채도·저명도)
@include stage($ls-stage);
$ls-ink: #12160e; // 관측창 바닥 — 무대보다 한 톤 어두운 이끼검
$ls-bark: #d8c4a0; // 줄기 — 파스텔 베이지
$ls-bark2: #e6d8bc; // 어린 가지 — 밝은 크림베이지
$ls-leaf: #bfe3c8; // 잎 — 파스텔 민트
$ls-mote: #f2ead6; // 떠다니는 씨앗 점
$ls-mist: #dfe6d6; // 보조 글자 — 안개 톤
.ls {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --ls-* 를 받아 쓴다
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mote: #{$ls-mote};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 완성 나무 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mote: #f2ead6;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 재귀 가지(01) — L-시스템 최소 木. 줄기 F 를 규칙 F[+F][-F]F 로 다시 쓰고
// 거북이(F=전진, +=반시계 회전, -=시계 회전, [=상태 저장, ]=복귀)로 선분을 적분한다.
// [ ] 스택이 없으면 두 번째 갈래가 첫 갈래 끝에서 시작해 나무가 아니라 지그재그가 된다.
// 움직임 = 世代 성장 애니메이션. 6세대까지 자라면(약 1.5초) 2초 루프 안에서 잎 호흡(가지 끝
// 밝기 진동)으로 23/23 프레임을 채운다. 계산 축: 세대마다 문자열을 다시 쓰고 좌표를 적분.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var ANGLE = 26; // 가지 각도(도) — 25도에 가까운 낮은 각이 침엽수 실루엣
var RATIO = 0.74; // 세대마다 줄기가 짧아지는 비율
var CYCLE = 2.0; // 성장+호흡 주기(초) — 설정 초(2s)와 같아 한 루프
// ────────────────────────────────────────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark'); // 줄기 — 베이지
var C_BARK2 = tokRGB('--ls-bark2'); // 어린 가지 — 밝은 색
var C_LEAF = tokRGB('--ls-leaf'); // 잎 — 파스텔 민트
var C_INK = tokRGB('--ls-ink'); // 관측창 바닥
var C_MOTE = tokRGB('--ls-mote'); // 떠다니는 씨앗 점
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// L-시스템 문자열 재쓰기 — axiom F 를 규칙으로 n世代 전개
var RULE = { 'F': 'F[+F][-F]F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 세대별 문자열을 미리 전개해 둔다 — 매 프레임 재계산하지 않는다(문자 길이 3^gen)
var GENS = [];
for (var g = 0; g <= 6; g++) GENS.push(expand('F', g));
// 거북 해석 — 문자열을 선분 목록 {x1,y1,x2,y2,depth} 로 적분
function turtle(str, x0, y0, len0, angDeg) {
var segs = [];
var x = x0, y = y0, a = -Math.PI / 2; // 위를 향해 시작(캔버스 y축 아래방향 보정)
var stackX = [], stackY = [], stackA = [];
var len = len0;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var nx = x + Math.cos(a) * len, ny = y + Math.sin(a) * len;
segs.push({ x1: x, y1: y, x2: nx, y2: ny });
x = nx; y = ny;
} else if (ch === '+') {
a -= angDeg * Math.PI / 180;
} else if (ch === '-') {
a += angDeg * Math.PI / 180;
} else if (ch === '[') {
stackX.push(x); stackY.push(y); stackA.push(a);
} else if (ch === ']') {
x = stackX.pop(); y = stackY.pop(); a = stackA.pop();
len *= 0; len += RATIO; // 닫힌 가지 뒤 길이 단계는 유지 — 아래에서 세대별로 다시 계산
}
}
return segs;
}
// 간단화: depth 는 스택 깊이로 세며 선분에 새기는 두 번째 구현이 아래 render 안에 있다.
// 떠다니는 씨앗 점 7개 — 성장 전 광합성 모티프(결정적 위상)
var MOTES = [];
for (var m = 0; m < 7; m++) {
MOTES.push({ px: 0.12 + 0.76 * ((m * 0.37) % 1), py: 0.2 + 0.5 * ((m * 0.61) % 1), ph: m * 0.9 });
}
function render(loop) {
var ctx = cv.getContext('2d');
// 성장 스케줄(2초 루프): 0~0.72 世代 0→6 성장, 0.72~1.0 완전 상태 유지+잎 호흡
var grPhase = Math.min(loop / 0.72, 1);
var genF = grPhase * 6; // 실수 世代 — 世代 사이를 선형으로 지나며 자란다
var gen = Math.min(6, Math.floor(genF));
var frac = genF - gen; // 현재 世代 안 진행률
var breath = 0.5 + 0.5 * Math.sin(loop / CYCLE * Math.PI * 6); // 잎 밝기 진동
// 바닥
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 떠다니는 씨앗 — 성장 초기(世代 0~2)에 잘 보이고 나중에도 느리게 떠 있는다
for (var mo = 0; mo < MOTES.length; mo++) {
var M = MOTES[mo];
var wx = M.px + 0.03 * Math.sin(loop / CYCLE * Math.PI * 2 + M.ph);
var wy = M.py + 0.05 * Math.cos(loop / CYCLE * Math.PI * 2 + M.ph * 1.3);
ctx.globalAlpha = 0.5 + 0.3 * Math.sin(loop / CYCLE * Math.PI * 4 + M.ph);
ctx.fillStyle = 'rgb(' + C_MOTE[0] + ',' + C_MOTE[1] + ',' + C_MOTE[2] + ')';
ctx.beginPath();
ctx.arc(wx * W, wy * H, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 뿌리 지면 — 얕은 실선
ctx.strokeStyle = 'rgba(' + C_BARK[0] + ',' + C_BARK[1] + ',' + C_BARK[2] + ',0.30)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.12, H * 0.92);
ctx.lineTo(W * 0.88, H * 0.92);
ctx.stroke();
// 성장 — 이전 世代(전체)를 밝게 깔고 현재 世代 마지막 분기를 이어 그린다
var baseLen = H * 0.24;
var x0 = W * 0.5, y0 = H * 0.92;
var strFull = GENS[gen];
if (gen > 0 && frac > 0) {
// 이전 세대를 완성형으로 깔고(어둡게), 현재 세대의 새 선분을 frac 비율로 이어 그린다.
drawTree(ctx, GENS[gen - 1], x0, y0, baseLen, 0.85, 0, gen - 1, 0);
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, frac, gen, breath);
} else {
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, gen === 0 ? Math.min(1, grPhase * 6) : 1, gen, breath);
}
}
// 거북 렌더러 — drawFrac: 마지막 세대의 새 선분은 frac 길이로.
// depth 는 스택 깊이 — 世代 번호를 그대로 색·굵기 감쇠 파라미터로 쓴다(077 기준 계산 축).
function drawTree(ctx, str, x0, y0, len0, dim, drawFrac, gen, breath) {
var x = x0, y = y0, a = -Math.PI / 2;
var stackX = [], stackY = [], stackA = [], stackL = [];
var depth = 0;
var maxDepth = Math.max(1, gen);
// 규칙 F[+F][-F]F 에서 한 세대에 F 세 개가 새로 생긴다 — 문자 인덱스로 새 선분 판별 대신
// 스택 깊이(=depth) 로 세대를 근사: depth 가 gen-1 이면 마지막 세대의 새 가지다.
var isNew = false;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var segLen = len0 * Math.pow(RATIO, depth);
var f = (depth >= gen && gen > 0) || (gen === 0) ? drawFrac : 1;
// depth >= gen 인 선분 = 이번 세대에 새로 생긴 선분 → 부분 길이로 성장
var nx = x + Math.cos(a) * segLen * f;
var ny = y + Math.sin(a) * segLen * f;
// 색: 깊을수록 어린 가지 — 베이지 → 밝은색, 끝은 잎색
var t = Math.min(1, depth / maxDepth);
var cr = C_BARK[0] + (C_BARK2[0] - C_BARK[0]) * t;
var cg = C_BARK[1] + (C_BARK2[1] - C_BARK[1]) * t;
var cb = C_BARK[2] + (C_BARK2[2] - C_BARK[2]) * t;
var isTip = (depth >= maxDepth);
if (isTip) {
var bl = 0.55 + 0.45 * breath; // 잎 호흡 — 끝 노드 밝기 진동
cr = C_BARK2[0] + (C_LEAF[0] - C_BARK2[0]) * bl;
cg = C_BARK2[1] + (C_LEAF[1] - C_BARK2[1]) * bl;
cb = C_BARK2[2] + (C_LEAF[2] - C_BARK2[2]) * bl;
}
ctx.globalAlpha = dim;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(1, 5 * Math.pow(0.62, depth));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
if (f >= 1) { x = nx; y = ny; } // 부분 성장 중에는 끝점을 옮기지 않는다
// 새 가지 끝에 잎 점
if (isTip && f >= 1) {
ctx.fillStyle = 'rgb(' + C_LEAF[0] + ',' + C_LEAF[1] + ',' + C_LEAF[2] + ')';
ctx.globalAlpha = dim * (0.16 + 0.18 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = dim * (0.4 + 0.45 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 2.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
} else if (ch === '+') {
a -= ANGLE * Math.PI / 180;
} else if (ch === '-') {
a += ANGLE * Math.PI / 180;
} else if (ch === '[') {
stackX.push(x); stackY.push(y); stackA.push(a); stackL.push(depth);
depth++;
} else if (ch === ']') {
x = stackX.pop(); y = stackY.pop(); a = stackA.pop(); depth = stackL.pop();
}
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE; // 0~1 루프 위상
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.6 : 0.8);
});
fit();
if (reduce) {
render(0.6); // reduce — 완성 木 한 장만 고정
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysTreeRecursiveBranch — vanilla 판(run/328/demo/01-lsys-tree-recursive-branch/index.html) 의 React 포트.
// 줄기 F 를 규칙 F[+F][-F]F 로 세대마다 다시 쓰고 거북이(F=전진, +/-=회전, [/]=스택)로 선분을 적분한다.
// [ ] 스택이 없으면 두 번째 갈래가 첫 갈래 끝에서 시작해 나무가 아니라 지그재그가 된다.
// 움직임 = 세대 성장 애니메이션(0→6세대, 약 1.5초) + 완성 뒤 잎 호흡(가지 끝 밝기 진동).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysTreeRecursiveBranch.module.scss";
export type LsysTreeRecursiveBranchProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number; // 가지 각도(도) — 25도에 가까운 낮은 각이 침엽수 실루엣
ratio?: number; // 세대마다 줄기가 짧아지는 비율
cycle?: number; // 성장+호흡 주기(초) — 설정 초(2s)와 같아 한 루프
};
const ANGLE = 26;
const RATIO = 0.74;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const HINT_TEXT = "F 하나는 앞으로, + 하나는 회전입니다. F → F[+F][-F]F 를 여섯 세대 다시 쓰는 것만으로 가지가 저절로 뻗는데, 자라는 속도를 한 세대씩 끊어 보면 규칙이 어디서 분기하는지 보입니다";
const META_LEFT = "규칙 F[+F][-F]F · 각도 26° · 세대 0 → 6";
const META_RIGHT = "canvas · 문자 재쓰기 · requestAnimationFrame";
const FIELD_LABEL = "재귀 가지 실험판";
const RULE: Record<string, string> = { F: "F[+F][-F]F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
const GENS: string[] = [];
for (let g = 0; g <= 6; g++) GENS.push(expand("F", g));
// 떠다니는 씨앗 점 7개 — 성장 전 광합성 모티프(결정적 위상)
type Mote = { px: number; py: number; ph: number };
const MOTES: Mote[] = [];
for (let m = 0; m < 7; m++) {
MOTES.push({ px: 0.12 + 0.76 * ((m * 0.37) % 1), py: 0.2 + 0.5 * ((m * 0.61) % 1), ph: m * 0.9 });
}
export default function LsysTreeRecursiveBranch({
duration = "2s",
easing = "ease-out",
color = "#d8c4a0",
angle = ANGLE,
ratio = RATIO,
cycle = CYCLE
}: LsysTreeRecursiveBranchProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ bark: RGB; bark2: RGB; leaf: RGB; ink: RGB; mote: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, str: string, x0: number, y0: number, len0: number, dim: number, drawFrac: number, gen: number, breath: number): void {
const col = colRef.current;
if (!col) return;
let x = x0, y = y0, a = -Math.PI / 2;
const stackX: number[] = [], stackY: number[] = [], stackA: number[] = [], stackL: number[] = [];
let depth = 0;
const maxDepth = Math.max(1, gen);
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const segLen = len0 * Math.pow(ratio, depth);
const f = (depth >= gen && gen > 0) || gen === 0 ? drawFrac : 1;
const nx = x + Math.cos(a) * segLen * f;
const ny = y + Math.sin(a) * segLen * f;
const t = Math.min(1, depth / maxDepth);
let cr = col.bark[0] + (col.bark2[0] - col.bark[0]) * t;
let cg = col.bark[1] + (col.bark2[1] - col.bark[1]) * t;
let cb = col.bark[2] + (col.bark2[2] - col.bark[2]) * t;
const isTip = depth >= maxDepth;
if (isTip) {
const bl = 0.55 + 0.45 * breath;
cr = col.bark2[0] + (col.leaf[0] - col.bark2[0]) * bl;
cg = col.bark2[1] + (col.leaf[1] - col.bark2[1]) * bl;
cb = col.bark2[2] + (col.leaf[2] - col.bark2[2]) * bl;
}
ctx.globalAlpha = dim;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(1, 5 * Math.pow(0.62, depth));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
if (f >= 1) { x = nx; y = ny; }
if (isTip && f >= 1) {
ctx.fillStyle = "rgb(" + col.leaf[0] + "," + col.leaf[1] + "," + col.leaf[2] + ")";
ctx.globalAlpha = dim * (0.16 + 0.18 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = dim * (0.4 + 0.45 * breath);
ctx.beginPath();
ctx.arc(nx, ny, 2.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
} else if (ch === "+") {
a -= angle * Math.PI / 180;
} else if (ch === "-") {
a += angle * Math.PI / 180;
} else if (ch === "[") {
stackX.push(x); stackY.push(y); stackA.push(a); stackL.push(depth);
depth++;
} else if (ch === "]") {
x = stackX.pop() as number; y = stackY.pop() as number; a = stackA.pop() as number; depth = stackL.pop() as number;
}
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
const grPhase = Math.min(loop / 0.72, 1);
const genF = grPhase * 6;
const gen = Math.min(6, Math.floor(genF));
const frac = genF - gen;
const breath = 0.5 + 0.5 * Math.sin(loop / cycle * Math.PI * 6);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
for (let mo = 0; mo < MOTES.length; mo++) {
const M = MOTES[mo];
const wx = M.px + 0.03 * Math.sin(loop / cycle * Math.PI * 2 + M.ph);
const wy = M.py + 0.05 * Math.cos(loop / cycle * Math.PI * 2 + M.ph * 1.3);
ctx.globalAlpha = 0.5 + 0.3 * Math.sin(loop / cycle * Math.PI * 4 + M.ph);
ctx.fillStyle = "rgb(" + col.mote[0] + "," + col.mote[1] + "," + col.mote[2] + ")";
ctx.beginPath();
ctx.arc(wx * W, wy * H, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.strokeStyle = "rgba(" + col.bark[0] + "," + col.bark[1] + "," + col.bark[2] + ",0.30)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.12, H * 0.92);
ctx.lineTo(W * 0.88, H * 0.92);
ctx.stroke();
const baseLen = H * 0.24;
const x0 = W * 0.5, y0 = H * 0.92;
const strFull = GENS[gen];
if (gen > 0 && frac > 0) {
drawTree(ctx, GENS[gen - 1], x0, y0, baseLen, 0.85, 0, gen - 1, 0);
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, frac, gen, breath);
} else {
drawTree(ctx, strFull, x0, y0, baseLen, 1.0, gen === 0 ? Math.min(1, grPhase * 6) : 1, gen, breath);
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
bark2: tokRGB(root, "--ls-bark2"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink"),
mote: tokRGB(root, "--ls-mote")
};
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(reduce ? 0.8 : 0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.8);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, ratio, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}01 · 再帰枝 — 2又に分かれる最小の木 — 計測 累積面積 2.123% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
規則と回転角
axiom は文字 F 一つだけで、規則は F を F[+F][-F]F に置き換えるものです。角括弧は現在の位置・向き・長さをスタックへ push/pop する記号で、これが「幹から2本の枝が分かれる」を文字列だけで表しています。
// Lシステム文字列書き換え — axiom F を規則で n世代分展開する
var RULE = { 'F': 'F[+F][-F]F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
呼吸するとどまり
世代0から世代6までの文字列をあらかじめ配列に保存しておき、進行度に合わせて前世代の点を次世代の点へ混ぜて滑らかに伸ばします。育ち切った保持区間では枝先の座標にサイン波の微小なずれを足すことで、成長が止まっても画面が完全な静止画にならないようにしています。
02ピタゴラスの木 — 正方形の上に正方形
正方形の斜辺の上に、その斜辺を一辺とする正方形を2つ載せる再帰を世代0→8まで繰り返すと、樽型の森が育ちます。実測候補にもある古典フラクタルをキャンバスで蘇らせる項目です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>正方形の斜辺を一辺とする正方形を2つ重ねる規則を0→1→2世代とたどると、管のような森が育ちます。世代が増えるほど面積は減り辺は増え、フラクタル次元は2に近づきます</span>
</p>
<div class="ls__field" role="application" aria-label="ピタゴラスの木ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">一辺s · 縮小0.707 · 世代0→8</span>
<span class="ls__meta ls__meta--dim">canvas · fillRect · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 피타고라스 나무(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(재귀 정사각형 세대 성장 + 계절 색 순환)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01 과 동일 팔레트, --ls-leaf2 만 이 편부터 추가).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
// ── 편 공통 숲 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8; // 여름 잎 — 파스텔 민트
$ls-leaf2: #eddfb4; // 가을 잎 — 파스텔 앰버
$ls-mote: #f2ead6;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-leaf2: #{$ls-leaf2};
--ls-mote: #{$ls-mote};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-leaf2: #eddfb4;
--ls-mote: #f2ead6;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 피타고라스 木(02) — 정사각형 위에 직각삼각형(빗변을 아래 변으로)을 얹고
// 그 빗변에 다시 정사각형 둘을 붙이는 재귀. L-시스템 문자열로도 쓸 수 있지만(0 → 1[1]1)
// 여기선 재귀 함수가 문자열 전개와 같은 나무를 만든다는 대응을 보여준다.
// 움직임 = 世代 성장(0→8) + 자란 뒤 계절 색 순환(초록 여름 → 호박 가을 → 다시 봄).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var SIDE0 = 0.21; // 첫 정사각형 한 변(무대 세로 배율) — 피사체를 키워 누적면적을 확보
var CYCLE = 2.0; // 성장+계절 주기(초)
var MAXGEN = 8; // 최대 世代 — 8 이면 사각형 약 250개(2^8 근사, 캔버스 서브픽셀 컬링됨)
// ────────────────────────────────────────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_TRUNK = tokRGB('--ls-bark'); // 몸통 — 베이지
var C_LEAFA = tokRGB('--ls-leaf'); // 여름 잎 — 파스텔 민트
var C_LEAFB = tokRGB('--ls-leaf2'); // 가을 잎 — 파스텔 앰버
var C_INK = tokRGB('--ls-ink');
var C_LINE = tokRGB('--ls-bark2');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 계절 보간 — t: 0(민트 여름) → 1(앰버 가을). 프랙탈 캔버스 색 혼합
function mix(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgb(c) { return 'rgb(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ')'; }
function render(loop) {
var ctx = cv.getContext('2d');
// 성장 스케줄: 0~0.55 世代 0→8 성장, 0.55~1.0 완전 숲 + 계절 색 순환
var grPhase = Math.min(loop / 0.55, 1);
var genF = grPhase * MAXGEN;
var seasonT = 0.5 - 0.5 * Math.cos((loop - 0.55) / 0.45 * Math.PI * 2); // 가을→봄→가을 왕복
if (loop < 0.55) seasonT = 0;
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var s0 = H * SIDE0;
var cx = W * 0.5, cy = H * 0.94;
// 완성 뒤 아주 약한 바람 흔들림 — 밑동 고정, 숲 전체 가장자리 픽셀을 계속 갱신해
// 계절 색 전환 구간이 아닐 때도 화면이 죽지 않게 한다(08 의 큰 진폭 흔들림과는 다른 미세 진동)
var sway = loop >= 0.55 ? Math.sin(loop * Math.PI * 5) * 0.028 : 0;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(sway);
ctx.translate(-cx, -cy);
drawTree(ctx, cx - s0 / 2, cy - s0, s0, 0, genF, seasonT);
ctx.restore();
}
// (x, y) = 정사각형 왼쪽 위, s = 한 변, gen = 남은 世代
function drawTree(ctx, x, y, s, gen, genF, seasonT) {
if (s < 0.9 || gen > genF) return;
var thisF = Math.min(1, genF - gen); // 현재 世代 안 진행률 — 부분 크기로 스르륵
var drawS = s * thisF;
if (drawS < 0.5) return;
// 색: 몸통(베이지) → 잎(계절 보간). 깊이 비율로 혼합
var depthT = Math.min(1, gen / MAXGEN);
var leaf = mix(C_LEAFA, C_LEAFB, seasonT);
var col = mix(C_TRUNK, leaf, depthT);
ctx.fillStyle = rgb(col);
// ctx.translate+rotate 대신 꼭짓점 직접 계산 — 정사각형이 기울어져도 fillRect 회전 근사가 아닌 정확 좌표
ctx.beginPath();
ctx.moveTo(x, y + s);
ctx.lineTo(x + s, y + s);
ctx.lineTo(x + s, y);
ctx.lineTo(x, y);
ctx.closePath();
// 성장 중 부분 크기: 아래 변 고정, 위로 줄어든 높이만큼만
if (thisF < 1) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y + s * (1 - thisF), s, s * thisF);
ctx.clip();
}
ctx.fill();
// 변 강조선 — 셸핀스키 카페트(05) 와 구별되는 정사각형 테두리
ctx.strokeStyle = 'rgba(' + C_LINE[0] + ',' + C_LINE[1] + ',' + C_LINE[2] + ',0.28)';
ctx.lineWidth = 0.75;
ctx.stroke();
if (thisF < 1) ctx.restore();
if (gen + 1 > genF) return;
// 위에 직각삼각형(45-45-90) — 빗변이 이 변. 두 정사각형을 빗변에 얹는다.
// 빗변 양끝 (x, y) (x+s, y). 45도 삼각형의 꼭대기는 중점 위 s/2.
var mx = x + s / 2, my = y - s / 2;
var NS = s / Math.SQRT2;
// 왼쪽 사각형: 빗변 (x,y)-(mx,my). 변 NS.
var angL = -Math.PI / 4; // -45도
var lx = x, ly = y;
// 왼쪽 사각형의 왼쪽 위 꼭짓점 = 빗변 시작점에서 밖으로 NS 만큼
var lcx = lx + Math.cos(-Math.PI * 3 / 4) * NS; // 225도 방향
var lcy = ly + Math.sin(-Math.PI * 3 / 4) * NS;
drawTree(ctx, lcx, lcy, NS, gen + 1, genF, seasonT);
// 오른쪽 사각형: 빗변 (mx,my)-(x+s,y).
var angR = Math.PI / 4;
var rx = mx, ry = my;
var rcx = rx + Math.cos(Math.PI * 3 / 4) * NS; // 135도 방향
var rcy = ry + Math.sin(Math.PI * 3 / 4) * NS;
drawTree(ctx, rcx, rcy, NS, gen + 1, genF, seasonT);
// 미세 진동 — 숲이 다 자란 뒤 미세하게 숨쉰다(눈에 거스리지 않는 0.3px)
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.6 : 0.8);
});
fit();
if (reduce) {
render(0.6);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysPythagorasTree — vanilla 판(run/328/demo/02-lsys-pythagoras-tree/index.html) 의 React 포트.
// 정사각형 위에 직각삼각형(빗변을 아래 변으로)을 얹고 그 빗변에 다시 정사각형 둘을 붙이는 재귀.
// 움직임 = 세대 성장(0→8) + 자란 뒤 계절 색 순환(초록 여름 → 호박 가을 → 다시 봄) + 미세 흔들림.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysPythagorasTree.module.scss";
export type LsysPythagorasTreeProps = {
duration?: string;
easing?: string;
color?: string;
side0?: number; // 첫 정사각형 한 변(무대 세로 배율)
cycle?: number;
maxGen?: number; // 최대 세대
};
const SIDE0 = 0.21;
const CYCLE = 2.0;
const MAXGEN = 8;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const HINT_TEXT = "정사각형 위에 빗변을 밑변으로 삼는 정사각형 둘을 얹는 규칙을 0 → 1 → 2 세대로 되짚으면 관 모양 숲이 자라는데, 세대가 늘수록 면은 줄고 변은 늘어나 프랙탈 차원이 2 에 다가갑니다";
const META_LEFT = "한 변 s · 축소 0.707 · 세대 0 → 8";
const META_RIGHT = "canvas · fillRect · requestAnimationFrame";
const FIELD_LABEL = "피타고라스 나무 실험판";
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgb(c: RGB): string {
return "rgb(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + ")";
}
export default function LsysPythagorasTree({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
side0 = SIDE0,
cycle = CYCLE,
maxGen = MAXGEN
}: LsysPythagorasTreeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ trunk: RGB; leafA: RGB; leafB: RGB; ink: RGB; line: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, gen: number, genF: number, seasonT: number): void {
const col = colRef.current;
if (!col) return;
if (s < 0.9 || gen > genF) return;
const thisF = Math.min(1, genF - gen);
const drawS = s * thisF;
if (drawS < 0.5) return;
const depthT = Math.min(1, gen / maxGen);
const leaf = mix(col.leafA, col.leafB, seasonT);
const c = mix(col.trunk, leaf, depthT);
ctx.fillStyle = rgb(c);
ctx.beginPath();
ctx.moveTo(x, y + s);
ctx.lineTo(x + s, y + s);
ctx.lineTo(x + s, y);
ctx.lineTo(x, y);
ctx.closePath();
if (thisF < 1) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y + s * (1 - thisF), s, s * thisF);
ctx.clip();
}
ctx.fill();
ctx.strokeStyle = "rgba(" + col.line[0] + "," + col.line[1] + "," + col.line[2] + ",0.28)";
ctx.lineWidth = 0.75;
ctx.stroke();
if (thisF < 1) ctx.restore();
if (gen + 1 > genF) return;
const mx = x + s / 2, my = y - s / 2;
const NS = s / Math.SQRT2;
const lx = x, ly = y;
const lcx = lx + Math.cos(-Math.PI * 3 / 4) * NS;
const lcy = ly + Math.sin(-Math.PI * 3 / 4) * NS;
drawTree(ctx, lcx, lcy, NS, gen + 1, genF, seasonT);
const rx = mx, ry = my;
const rcx = rx + Math.cos(Math.PI * 3 / 4) * NS;
const rcy = ry + Math.sin(Math.PI * 3 / 4) * NS;
drawTree(ctx, rcx, rcy, NS, gen + 1, genF, seasonT);
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
const grPhase = Math.min(loop / 0.55, 1);
const genF = grPhase * maxGen;
let seasonT = 0.5 - 0.5 * Math.cos((loop - 0.55) / 0.45 * Math.PI * 2);
if (loop < 0.55) seasonT = 0;
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const s0 = H * side0;
const cx = W * 0.5, cy = H * 0.94;
const sway = loop >= 0.55 ? Math.sin(loop * Math.PI * 5) * 0.028 : 0;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(sway);
ctx.translate(-cx, -cy);
drawTree(ctx, cx - s0 / 2, cy - s0, s0, 0, genF, seasonT);
ctx.restore();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
trunk: tokRGB(root, "--ls-bark"),
leafA: tokRGB(root, "--ls-leaf"),
leafB: tokRGB(root, "--ls-leaf2"),
ink: tokRGB(root, "--ls-ink"),
line: tokRGB(root, "--ls-bark2")
};
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.6);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [side0, cycle, maxGen]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}02 · ピタゴラスの木 — 正方形の上に正方形 — 計測 累積面積 1.792% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
2つの正方形が育つ場所
最初の正方形の一辺は舞台の縦幅の0.21倍、そこから世代ごとに1/√2(約0.707倍)ずつ縮みます。斜辺の中点と√2で割った新しい辺の長さから、左右2つの子正方形の基準点を三角関数で求め、それぞれを自分自身の再帰呼び出しに渡します。
if (gen + 1 > genF) return;
// 上に直角三角形(45-45-90) — 斜辺がこの辺。2つの正方形を斜辺に載せる。
// 斜辺の両端は (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;
var lx = x, ly = y;
// 左の正方形の左上頂点 = 斜辺の始点から外側へ NS だけ
var lcx = lx + Math.cos(-Math.PI * 3 / 4) * NS;
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;
var rcy = ry + Math.sin(Math.PI * 3 / 4) * NS;
drawTree(ctx, rcx, rcy, NS, gen + 1, genF, seasonT);
季節が巡る色
正方形の色は幹色から葉色へ、再帰の深さの割合(depthT)で混ぜています。育ち切った後は葉色そのものが緑の夏から琥珀色の秋へ、また春へと周期の中で巡り、8世代まで進むと正方形はおよそ250個(2の8乗の近似、キャンバスの外に出た分はサブピクセルで自然に間引かれます)まで増えます。
03コッホ雪片 — 辺を持ち上げて無限の海岸線
axiom F--F--F(正三角形の3辺)から規則 F→F+F--F+F を世代ごとに適用すると、毎回さらに1辺が細かく折れて雪片の海岸線が伸びます。日本語の実測候補(コッホ雪片とは・次元)まで出る検索軸の代表項目です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>F → F+F--F+F という一行の書き換えを繰り返すと、三角形の三辺が毎回さらに細かく折れて雪片の海岸線が伸びます。世代が増えるほど周囲は伸び、内側の面積はほぼ変わらず、フラクタル次元が現れます</span>
</p>
<div class="ls__field" role="application" aria-label="コッホ雪片ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 F→F+F--F+F · 角度60° · 世代 <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · 文字列書き換え · strokeStyle</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 코흐 눈송이(03). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(세대 크로스페이드 성장 + 둘레를 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01·02 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 코흐 눈송이(03) — 정삼각형(F--F--F)의 각 변을 F+F--F+F 로 되풀이해 다시 쓰면
// 가운데를 들어올린 산 모양이 매번 새로 끼어들어 해안선이 무한히 길어진다(둘레 → ∞, 넓이 → 유한).
// 움직임 = 世代 크로스페이드 성장(0→4) + 완성 뒤 둘레를 도는 빛 한 점(strokeStyle 순환).
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 60;
var MAXGEN = 4;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'F': 'F+F--F+F' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — 문자열을 점 목록(닫힌 다각형)으로 적분
function turtlePoly(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a -= ANGLE * Math.PI / 180;
} else if (ch === '-') {
a += ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'F--F--F';
var POLY = []; // 세대별 정규화 점 목록(0~1 정사각 프레임)
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
// gen 0 은 변 길이 1로, 세대가 늘수록 변 개수가 3*4^g — step 을 3^g 에 반비례시켜 전체 크기를 맞춘다
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) {
var step = 1 / Math.pow(3, g2) * (g2 === 0 ? 1 : 1);
raw.push(turtlePoly(strs[g2], step));
}
// 최종 世代 기준 bbox 로 전체를 정규화(모든 세대가 같은 프레임 안에 겹치도록)
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPoly(ctx, pts, ox, oy, sc, col, alpha, lw, fillAlpha) {
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.closePath();
if (fillAlpha > 0) {
ctx.fillStyle = rgb(col, fillAlpha);
ctx.fill();
}
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.78;
var ox = (W - sc) / 2, oy = (H - sc) / 2 + H * 0.03;
// 0~0.68 : 世代 크로스페이드 성장. 0.68~1.0 : 완성 눈송이 + 도는 빛
var grPhase = Math.min(loop / 0.68, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
// 안쪽 반투명 채움 — 윤곽선만으로는 면적이 작아 보인다. 채움 알파를 계속 미세 진동시켜
// 완성 뒤(정지 구간)에도 픽셀이 계속 갱신되게 한다(피사체를 키운다 원칙).
var fillA = 0.16 + 0.06 * Math.sin(loop * Math.PI * 5);
drawPoly(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, 1.4, fillA);
if (f > 0 && gB !== gA) drawPoly(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, 1.2, 0);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.68) {
// 둘레를 도는 빛 — 최종 世代 점 목록을 따라 위치를 진행시킨다(strokeStyle 색 순환)
var t = (loop - 0.68) / 0.32;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[0];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
var hue = 0.5 + 0.5 * Math.sin(loop * Math.PI * 4);
var glowCol = [C_GLOW[0] + (255 - C_GLOW[0]) * hue * 0.4, C_GLOW[1], C_GLOW[2] + (255 - C_GLOW[2]) * 0.2];
ctx.fillStyle = rgb(glowCol, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgb(glowCol, 0.35);
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(lx, ly, 5.5, 0, Math.PI * 2);
ctx.stroke();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysKochSnowflake — vanilla 판(run/328/demo/03-lsys-koch-snowflake/index.html) 의 React 포트.
// 정삼각형(F--F--F)의 각 변을 F+F--F+F 로 되풀이해 다시 쓰면 해안선이 무한히 길어진다(둘레 → ∞, 넓이 → 유한).
// 움직임 = 세대 크로스페이드 성장(0→4) + 완성 뒤 둘레를 도는 빛 한 점(strokeStyle 순환).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysKochSnowflake.module.scss";
export type LsysKochSnowflakeProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 60;
const MAXGEN = 4;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "F → F+F--F+F 문자 재쓰기 한 줄을 반복하면 삼각형 세 변이 매번 더 잘게 접혀 눈송이 해안선이 길어지는데, 세대가 늘수록 둘레는 늘고 안쪽 넓이는 거의 그대로라 프랙탈 차원이 드러납니다";
const META_LEFT_PREFIX = "규칙 F→F+F--F+F · 각도 60° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · strokeStyle";
const FIELD_LABEL = "코흐 눈송이 실험판";
const RULE: Record<string, string> = { F: "F+F--F+F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePoly(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a -= angle * Math.PI / 180;
} else if (ch === "-") {
a += angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "F--F--F";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) {
const step = 1 / Math.pow(3, g2);
raw.push(turtlePoly(strs[g2], step, angle));
}
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysKochSnowflake({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysKochSnowflakeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPoly(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number, fillAlpha: number): void {
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.closePath();
if (fillAlpha > 0) {
ctx.fillStyle = rgba(col, fillAlpha);
ctx.fill();
}
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.78;
const ox = (W - sc) / 2, oy = (H - sc) / 2 + H * 0.03;
const grPhase = Math.min(loop / 0.68, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
const fillA = 0.16 + 0.06 * Math.sin(loop * Math.PI * 5);
drawPoly(ctx, POLY[gA], ox, oy, sc, col.line, 1, 1.4, fillA);
if (f > 0 && gB !== gA) drawPoly(ctx, POLY[gB], ox, oy, sc, col.glow, f, 1.2, 0);
setGen(gA);
if (loop >= 0.68) {
const t = (loop - 0.68) / 0.32;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[0];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
const hue = 0.5 + 0.5 * Math.sin(loop * Math.PI * 4);
const glowCol: RGB = [col.glow[0] + (255 - col.glow[0]) * hue * 0.4, col.glow[1], col.glow[2] + (255 - col.glow[2]) * 0.2];
ctx.fillStyle = rgba(glowCol, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgba(glowCol, 0.35);
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(lx, ly, 5.5, 0, Math.PI * 2);
ctx.stroke();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}03 · コッホ雪片 — 辺を持ち上げて無限の海岸線 — 計測 累積面積 8.315% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
三角形から伸びる海岸線
角度は60度固定で、世代0から世代4まで、前世代の点を次世代の点へ混ぜながら補間します。1本の辺が4本に置き換わる規則なので、世代を1つ進めるごとに辺の総数はおよそ4倍に増え、周の長さそのものが伸び続けます。
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;
}
輪の呼吸と光の点
塗りの不透明度がサイン波でわずかに明滅する「呼吸」の上に、周囲を回る装飾的な光の点を arc で重ねています。構造そのものは直線(lineTo)だけで、曲線関数は種のような強調点にしか使っていません。
04シェルピンスキーの三角形 — 三角形に潜む三角形
タートル文字数文字で三角形の中に三角形が無限に並びます。シェルピンスキーのギャスケットとも呼ばれる図形で、日本語実測で最も厚い候補が出た軸のため、他ロケールへの展開も容易な項目です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>A→B-A-B、B→A+B+A の2文字を60°の亀歩きで展開すると一筆書きの矢じり曲線になります。世代を重ねるほど、その曲線がシェルピンスキーの三角形の隙間をそのまま埋めていきます</span>
</p>
<div class="ls__field" role="application" aria-label="シェルピンスキーの三角形ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 A→B-A-B · 角度60° · 世代 <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · 文字列書き換え · beginPath</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 셸핀스키 삼각형(04). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(화살촉 곡선 세대 크로스페이드 + 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~03 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 셸핀스키 삼각형(04) — 화살촉(Arrowhead) L-시스템: A,B 두 기호가 서로를 끼워 넣으며
// 자라는 한붓그리기 곡선이 세대가 늘수록 삼각형 가스켓의 구멍을 정확히 남긴다.
// 움직임 = 世代 크로스페이드 성장(0→6) + 완성 뒤 곡선을 따라 도는 빛 한 점.
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 60;
var MAXGEN = 6;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'A': 'B-A-B', 'B': 'A+B+A' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — A,B 모두 전진 한 걸음, +,- 는 60도 회전. 열린 곡선 점 목록을 만든다.
function turtlePath(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'A' || ch === 'B') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a -= ANGLE * Math.PI / 180;
} else if (ch === '-') {
a += ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'A';
var POLY = [];
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) raw.push(turtlePath(strs[g2], 1 / Math.pow(2, g2)));
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPath(ctx, pts, ox, oy, sc, col, alpha, lw) {
if (!pts.length) return;
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.82;
var ox = (W - sc) / 2, oy = (H - sc) / 2;
var grPhase = Math.min(loop / 0.68, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
// 세대가 늘수록 선분 간격이 좁아지므로 굵기를 간격에 맞춰 키운다 —
// 화살촉 곡선이 스스로 겹치며 가스켓 안쪽을 실제로 채운다(피사체를 키운다 원칙)
var lwA = Math.min(4.2, Math.max(1.3, sc / Math.pow(2, gA) * 0.62));
var lwB = Math.min(4.2, Math.max(1.1, sc / Math.pow(2, gB) * 0.62));
drawPath(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, lwA);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, lwB);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.68) {
var t = (loop - 0.68) / 0.32;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgb(C_GLOW, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3, 0, Math.PI * 2);
ctx.fill();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysSierpinskiTriangle — vanilla 판(run/328/demo/04-lsys-sierpinski-triangle/index.html) 의 React 포트.
// 화살촉(Arrowhead) L-시스템: A,B 두 기호가 서로를 끼워 넣으며 자라는 한붓그리기 곡선이
// 세대가 늘수록 삼각형 가스켓의 구멍을 정확히 남긴다.
// 움직임 = 세대 크로스페이드 성장(0→6) + 완성 뒤 곡선을 따라 도는 빛 한 점.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysSierpinskiTriangle.module.scss";
export type LsysSierpinskiTriangleProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 60;
const MAXGEN = 6;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "A→B-A-B, B→A+B+A 두 문자를 60° 거북 걸음으로 풀면 한붓그리기 화살촉 곡선이 되는데, 세대를 더할수록 그 곡선이 셸핀스키 삼각형의 빈틈을 그대로 메웁니다";
const META_LEFT_PREFIX = "규칙 A→B-A-B · 각도 60° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · beginPath";
const FIELD_LABEL = "셸핀스키 삼각형 실험판";
const RULE: Record<string, string> = { A: "B-A-B", B: "A+B+A" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePath(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "A" || ch === "B") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a -= angle * Math.PI / 180;
} else if (ch === "-") {
a += angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "A";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) raw.push(turtlePath(strs[g2], 1 / Math.pow(2, g2), angle));
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysSierpinskiTriangle({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysSierpinskiTriangleProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPath(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number): void {
if (!pts.length) return;
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.82;
const ox = (W - sc) / 2, oy = (H - sc) / 2;
const grPhase = Math.min(loop / 0.68, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
const lwA = Math.min(4.2, Math.max(1.3, sc / Math.pow(2, gA) * 0.62));
const lwB = Math.min(4.2, Math.max(1.1, sc / Math.pow(2, gB) * 0.62));
drawPath(ctx, POLY[gA], ox, oy, sc, col.line, 1, lwA);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, col.glow, f, lwB);
setGen(gA);
if (loop >= 0.68) {
const t = (loop - 0.68) / 0.32;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgba(col.glow, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 3, 0, Math.PI * 2);
ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}04 · シェルピンスキーの三角形 — 三角形に潜む三角形 — 計測 累積面積 5.87% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
互いを書き換える2文字
axiom は A 一文字、規則は A→B-A-B と B→A+B+A の2つで、角度60度のタートル歩行に解釈すると一筆書きの矢じり曲線になります。A と B が互いを呼び合いながら書き換わることで、世代0→6と増えるほどその曲線が三角形の隙間をそのまま埋めていきます。
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;
}
太さが世代で細る理由
線の太さは世代が進むほど細くします。1本の線が担う三角形が世代ごとに小さくなるため、太いまま描くと隙間が線に埋まって見えてしまうからです。世代番号から太さを逆算する式を使い、最小1.3px・最大4.2pxの範囲に収めています。
05シェルピンスキーのカーペット — マスを抜って掘る穴
9マスごとに中央を空ける規則を繰り返すと、布に穴が開くように正方形が掘れます。面積が減り辺が増えるフラクタル次元を、トゥイーンではなく測定で示す項目です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>塗りつぶし1マス(1)を3×3の九マスに書き換え、中央だけ穴(0)にする操作を毎世代繰り返すと、残る面積が8/9ずつ減るカーペットに穴が開きます — getImageData で残った面積を実際に数えます</span>
</p>
<div class="ls__field" role="application" aria-label="シェルピンスキーのカーペットラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">世代 <b id="lsGen">0</b> · 面積 <b id="lsArea">100%</b></span>
<span class="ls__meta ls__meta--dim">fillRect · getImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 셸핀스키 카펫(05). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(격자 문자 재쓰기 세대 성장 + 밝기 호흡)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~04 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 셸핀스키 카펫(05) — 채움 칸 하나(1)를 3×3 아홉 칸으로 다시 쓰되 가운데만 구멍(0)으로
// 남기는 문자 재쓰기(1→111101111)를 세대마다 8칸에 재귀 적용한다. 채워진 픽셀 수를
// getImageData 로 직접 세어 "남은 넓이 %" 를 화면에 표시 — 트윈이 아니라 실측값이다.
(function () {
// ── 변형 상수 블록 ──
var MAXGEN = 4;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var areaEl = document.getElementById('lsArea');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_FILL = tokRGB('--ls-bark2');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
var lastAreaTick = -1;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 문자 재쓰기 규칙 — '1' 한 칸이 3x3 아홉 칸(가운데만 구멍)으로 다시 쓰인다.
var RULE = { '1': '111101111', '0': '000000000' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) out += RULE[s.charAt(i)];
s = out;
}
return s;
}
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
// gen 世代 문자열을 3^gen x 3^gen 격자로 해석해 채워진 칸만 fillRect
function drawCarpet(ctx, str, gen, ox, oy, side, thisF, bright) {
var n = Math.pow(3, gen);
var cell = side / n;
for (var idx = 0; idx < str.length; idx++) {
if (str.charAt(idx) === '0') continue;
var row = Math.floor(idx / n), col = idx % n;
var cx = ox + col * cell, cy = oy + row * cell;
var pad = cell * (1 - thisF) * 0.5;
ctx.fillRect(cx + pad, cy + pad, Math.max(0.4, cell - pad * 2), Math.max(0.4, cell - pad * 2));
}
}
function render(loop, now) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.84;
var ox = (W - side) / 2, oy = (H - side) / 2;
// 0~0.6 : 世代 성장(0→MAXGEN). 0.6~1.0 : 완성 카펫 + 밝기 호흡(자기유사 강조)
var grPhase = Math.min(loop / 0.6, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var thisF = gA >= MAXGEN ? 1 : Math.min(1, genF - gA);
var str = expand('1', gA);
var breath = loop < 0.6 ? 1 : 0.72 + 0.28 * Math.sin((loop - 0.6) / 0.4 * Math.PI * 3);
ctx.fillStyle = rgb(C_FILL, breath);
drawCarpet(ctx, str, gA, ox, oy, side, thisF, breath);
if (genEl) genEl.textContent = String(gA);
// 남은 넓이 % — getImageData 로 실제 채워진(배경이 아닌) 픽셀 비율을 센다. 매 프레임 다시 셀 필요는
// 없으므로(값은 세대에만 의존) 세대가 바뀔 때만 재계산해 계산량을 아낀다.
if (areaEl && gA !== lastAreaTick) {
lastAreaTick = gA;
try {
var img = ctx.getImageData(Math.round(ox), Math.round(oy), Math.round(side), Math.round(side));
var data = img.data, filled = 0, total = data.length / 4;
for (var p = 0; p < data.length; p += 4) {
if (data[p] !== C_INK[0] || data[p + 1] !== C_INK[1] || data[p + 2] !== C_INK[2]) filled++;
}
areaEl.textContent = Math.round((filled / total) * 100) + '%';
} catch (e) { /* 캔버스 판독 실패해도 화면은 그대로 동작 */ }
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop, now);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
lastAreaTick = -1;
render(0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysSierpinskiCarpet — vanilla 판(run/328/demo/05-lsys-sierpinski-carpet/index.html) 의 React 포트.
// 채움 칸 하나(1)를 3×3 아홉 칸으로 다시 쓰되 가운데만 구멍(0)으로 남기는 문자 재쓰기(1→111101111)를
// 세대마다 8칸에 재귀 적용한다. 채워진 픽셀 수를 getImageData 로 직접 세어 "남은 넓이 %" 를 표시 — 실측값.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysSierpinskiCarpet.module.scss";
export type LsysSierpinskiCarpetProps = {
duration?: string;
easing?: string;
color?: string;
maxGen?: number;
cycle?: number;
};
const MAXGEN = 4;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const HINT_TEXT = "문자 1(채움)을 3×3 아홉 칸 중 가운데만 0(구멍)으로 바꾼 아홉 글자로 되풀이해 바꿔 쓰면, 세대가 늘수록 남은 넓이가 8/9 씩 줄어드는 카펫이 뚫립니다 — getImageData 로 남은 넓이를 직접 셉니다";
const META_RIGHT = "fillRect · getImageData";
const FIELD_LABEL = "셸핀스키 카펫 실험판";
const RULE: Record<string, string> = { "1": "111101111", "0": "000000000" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) out += RULE[s.charAt(i)];
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysSierpinskiCarpet({
duration = "2s",
easing = "ease-spring",
color = "#e6d8bc",
maxGen = MAXGEN,
cycle = CYCLE
}: LsysSierpinskiCarpetProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ fill: RGB; ink: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const lastAreaTickRef = useRef(-1) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const [area, setArea] = useState("100%");
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawCarpet(ctx: CanvasRenderingContext2D, str: string, gen: number, ox: number, oy: number, side: number, thisF: number): void {
const n = Math.pow(3, gen);
const cell = side / n;
for (let idx = 0; idx < str.length; idx++) {
if (str.charAt(idx) === "0") continue;
const row = Math.floor(idx / n), col = idx % n;
const cx = ox + col * cell, cy = oy + row * cell;
const pad = cell * (1 - thisF) * 0.5;
ctx.fillRect(cx + pad, cy + pad, Math.max(0.4, cell - pad * 2), Math.max(0.4, cell - pad * 2));
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.84;
const ox = (W - side) / 2, oy = (H - side) / 2;
const grPhase = Math.min(loop / 0.6, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const thisF = gA >= maxGen ? 1 : Math.min(1, genF - gA);
const str = expand("1", gA);
const breath = loop < 0.6 ? 1 : 0.72 + 0.28 * Math.sin((loop - 0.6) / 0.4 * Math.PI * 3);
ctx.fillStyle = rgba(col.fill, breath);
drawCarpet(ctx, str, gA, ox, oy, side, thisF);
setGen(gA);
if (gA !== lastAreaTickRef.current) {
lastAreaTickRef.current = gA;
try {
const img = ctx.getImageData(Math.round(ox), Math.round(oy), Math.round(side), Math.round(side));
const data = img.data;
let filled = 0;
const total = data.length / 4;
for (let p = 0; p < data.length; p += 4) {
if (data[p] !== col.ink[0] || data[p + 1] !== col.ink[1] || data[p + 2] !== col.ink[2]) filled++;
}
setArea(Math.round((filled / total) * 100) + "%");
} catch (e) { /* 캔버스 판독 실패해도 화면은 그대로 동작 */ }
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
fill: tokRGB(root, "--ls-bark2"),
ink: tokRGB(root, "--ls-ink")
};
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
lastAreaTickRef.current = -1;
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>세대 <b>{gen}</b> · 넓이 <b>{area}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}05 · シェルピンスキーのカーペット — マスを抜って掘る穴 — 計測 累積面積 16.167% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
9マスの穴あけ規則
塗りつぶし1マス(文字1)を3×3の9文字に書き換え、中央だけを穴(文字0)にする規則 1→111101111 を世代0→4まで再帰適用し、結果の文字列を fillRect の格子として描きます。
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) { /* キャンバス読み取り失敗時も画面はそのまま動作する */ }
}
実測でしか出せない残存率
「残った面積%」の表示は getImageData で読んだ実際のピクセルを数えた値で、アニメーションの進行度をそのまま数値化したものではありません。育ち切った後は 0.72〜1.0 の範囲で明るさがサイン波で呼吸し、累積面積16.167%・強度平均141.3と9項目中もっとも明るいコントラストになります。
06ドラゴン曲線 — 折り紙の規則が育てる紙の恐竜
紐を半分に折る規則を文字に写すと、紙を折って作ったドラゴンが画面に現れます。Lシステムの2文字(X・Y)の相互書き換えで描く代表応用です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>FX の一行に X→X+YF+、Y→-FX-Y の二つの規則を繰り返し適用すると、紙を半分に折り続けた跡のように重ならずぎっしり折り重なるドラゴン曲線が育ちます</span>
</p>
<div class="ls__field" role="application" aria-label="ドラゴン曲線ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 X→X+YF+ · 角度90° · 世代 <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · 文字列書き換え · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 드래곤 커브(06). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자 재쓰기 세대 크로스페이드 + 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~05 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 드래곤 커브(06) — 종이 접기를 문자로 옮긴 L-시스템(FX 축, X→X+YF+, Y→-FX-Y, 90도).
// 세대가 늘수록 접힌 자국(F 전진, +/- 90도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = 世代 크로스페이드 성장(0→11) + 완성 뒤 곡선을 따라 도는 빛 한 점.
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 90;
var MAXGEN = 11;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'X': 'X+YF+', 'Y': '-FX-Y' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — F 만 전진(점 추가), X·Y 는 자리표시자(전진 없음), +/- 는 90도 회전.
function turtlePath(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a += ANGLE * Math.PI / 180;
} else if (ch === '-') {
a -= ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'FX';
var POLY = [];
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) raw.push(turtlePath(strs[g2], 1));
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPath(ctx, pts, ox, oy, sc, col, alpha, lw) {
if (pts.length < 2) return;
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.8;
var ox = (W - sc) / 2, oy = (H - sc) / 2;
var grPhase = Math.min(loop / 0.7, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
drawPath(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, 2.1);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, 1.9);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.7) {
var t = (loop - 0.7) / 0.3;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgb(C_GLOW, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysDragonCurve — vanilla 판(run/328/demo/06-lsys-dragon-curve/index.html) 의 React 포트.
// 종이 접기를 문자로 옮긴 L-시스템(FX 축, X→X+YF+, Y→-FX-Y, 90도).
// 세대가 늘수록 접힌 자국(F 전진, +/- 90도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = 세대 크로스페이드 성장(0→11) + 완성 뒤 곡선을 따라 도는 빛 한 점.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysDragonCurve.module.scss";
export type LsysDragonCurveProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 90;
const MAXGEN = 11;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "FX 한 줄에서 X→X+YF+, Y→-FX-Y 두 규칙을 되풀이 적용하면 종이를 반으로 접었다 편 자국처럼 겹치지 않고 빼곡히 접히는 용(드래곤) 곡선이 자랍니다";
const META_LEFT_PREFIX = "규칙 X→X+YF+ · 각도 90° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · requestAnimationFrame";
const FIELD_LABEL = "드래곤 커브 실험판";
const RULE: Record<string, string> = { X: "X+YF+", Y: "-FX-Y" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePath(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a += angle * Math.PI / 180;
} else if (ch === "-") {
a -= angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "FX";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) raw.push(turtlePath(strs[g2], 1, angle));
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysDragonCurve({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysDragonCurveProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPath(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number): void {
if (pts.length < 2) return;
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.8;
const ox = (W - sc) / 2, oy = (H - sc) / 2;
const grPhase = Math.min(loop / 0.7, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
drawPath(ctx, POLY[gA], ox, oy, sc, col.line, 1, 2.1);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, col.glow, f, 1.9);
setGen(gA);
if (loop >= 0.7) {
const t = (loop - 0.7) / 0.3;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgba(col.glow, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 · ドラゴン曲線 — 折り紙の規則が育てる紙の恐竜 — 計測 累積面積 5.192% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
紙を半分に折る規則
axiom FX の一行に X→X+YF+、Y→-FX-Y の2つの規則を繰り返し適用すると、紙を半分に折り続けた跡のように重ならずぎっしり折り重なる曲線が育ちます。角度は90度固定です。
var RULE = { 'X': 'X+YF+', 'Y': '-FX-Y' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
11世代まで伸ばす理由
世代を0→11まで伸ばすのは、折り重なりの密度が最後まで満ちる境目がそのあたりにあるからです。9項目の中でもっとも高い世代数ですが、文字列は世代ごとにしか展開しないので、11世代まで進めても毎フレームの計算量は一定に保たれます。
07パラメータ植物 — F+-[] 文字列が育つ葉
括弧で枝を覚える文字列に世代と角度を付けると観葉植物の葉が広がります。数文字変えるだけで別の植物になる生成文法の実験項目です。
![パラメータ植物 — F+-[] 文字列が育つ葉](https://ayo-oss.github.io/godrichstory-demo/lsystem-fractal-tree-canvas/07-lsys-plant-parametric/poster.png)
<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>X→F+[[X]-X]-F[-FX]+X の一行に F→FF を加え、角度と比率の2つの数値だけを変えると、同じ文法からシダから低木までまったく違う植物が育ちます — パラメータが種を決めます</span>
</p>
<div class="ls__field" role="application" aria-label="パラメトリック植物ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 X→F+[[X]-X]-F[-FX]+X · 角度 <b id="lsAng">25</b>°</span>
<span class="ls__meta ls__meta--dim">canvas · globalAlpha</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매개변수 식물(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자 재쓰기 세대 성장 + 분기각 매개변수 흔들림)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~06 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 매개변수 식물(07) — Prusinkiewicz 고전 식물 L-시스템(X 축, X→F+[[X]-X]-F[-FX]+X, F→FF).
// 대괄호 [ ] 로 위치·방향을 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = 世代 크로스페이드 성장(0→5) + 완성 뒤 각도를 서서히 흔들어(매개변수 자체가 시간 함수)
// "같은 문법·다른 매개변수 = 다른 식물"을 실시간으로 보여준다. 잎은 globalAlpha 로 깊이 페이드.
(function () {
// ── 변형 상수 블록 ──
var ANGLE0 = 25; // 기준 분기각(도) — 이 값이 흔들리며 식물 형태가 바뀐다
var SWING = 6; // 각도 흔들림 폭(도)
var MAXGEN = 5;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var angEl = document.getElementById('lsAng');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_STEM = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'X': 'F+[[X]-X]-F[-FX]+X', 'F': 'FF' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
var STRS = [];
for (var g0 = 0; g0 <= MAXGEN; g0++) STRS.push(expand('X', g0));
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 거북 렌더러 — angleDeg 는 매 프레임 바뀌는 매개변수(같은 문자열, 다른 각도 → 다른 식물)
function drawPlant(ctx, str, x0, y0, stepLen, angleDeg, ox, oy, sc, alphaMul, drawFrac) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var n = str.length;
var drawUpto = Math.floor(n * drawFrac);
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var nx = x + Math.cos(a) * stepLen;
var ny = y + Math.sin(a) * stepLen;
var depth = stack.length;
var t = Math.min(1, depth / 6);
var cr = C_STEM[0] + (C_LEAF[0] - C_STEM[0]) * t;
var cg = C_STEM[1] + (C_LEAF[1] - C_STEM[1]) * t;
var cb = C_STEM[2] + (C_LEAF[2] - C_STEM[2]) * t;
ctx.globalAlpha = alphaMul * (0.55 + 0.45 * t);
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(0.9, 3.2 * Math.pow(0.72, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= 4) {
ctx.globalAlpha = alphaMul * 0.5;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 3.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.fill();
}
x = nx; y = ny;
} else if (ch === '+') {
a += angleDeg * Math.PI / 180;
} else if (ch === '-') {
a -= angleDeg * Math.PI / 180;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a });
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; }
}
}
ctx.globalAlpha = 1;
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.16;
var ox = W * 0.5, oy = H * 0.95;
var grPhase = Math.min(loop / 0.55, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var drawFrac = gA >= MAXGEN ? 1 : Math.min(1, genF - gA);
// 성장 뒤 각도를 흔들어 매개변수가 형태를 바꾸는 걸 실시간으로 보여준다
var angT = loop >= 0.55 ? (loop - 0.55) / 0.45 : 0;
var angle = ANGLE0 + (loop >= 0.55 ? Math.sin(angT * Math.PI * 3) * SWING : 0);
if (angEl) angEl.textContent = String(Math.round(angle));
drawPlant(ctx, STRS[gA], 0, 0, 1 / Math.pow(1.55, gA) * 0.62, angle, ox, oy, sc, 1, gA >= MAXGEN ? 1 : drawFrac);
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysPlantParametric — vanilla 판(run/328/demo/07-lsys-plant-parametric/index.html) 의 React 포트.
// Prusinkiewicz 고전 식물 L-시스템(X 축, X→F+[[X]-X]-F[-FX]+X, F→FF). 대괄호 [ ] 로 위치·방향을
// 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = 세대 크로스페이드 성장(0→5) + 완성 뒤 각도를 서서히 흔들어 "같은 문법·다른 매개변수 = 다른 식물"을 보여준다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysPlantParametric.module.scss";
export type LsysPlantParametricProps = {
duration?: string;
easing?: string;
color?: string;
angle0?: number; // 기준 분기각(도)
swing?: number; // 각도 흔들림 폭(도)
maxGen?: number;
cycle?: number;
};
const ANGLE0 = 25;
const SWING = 6;
const MAXGEN = 5;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number };
const HINT_TEXT = "X→F+[[X]-X]-F[-FX]+X 한 줄에 F→FF 를 더해 각도·비율 두 숫자만 바꾸면 고사리부터 관목까지 전혀 다른 식물이 같은 문법에서 자라납니다 — 매개변수가 종을 결정합니다";
const META_LEFT_PREFIX = "규칙 X→F+[[X]-X]-F[-FX]+X · 각도 ";
const META_RIGHT = "canvas · globalAlpha";
const FIELD_LABEL = "매개변수 식물 실험판";
const RULE: Record<string, string> = { X: "F+[[X]-X]-F[-FX]+X", F: "FF" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysPlantParametric({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle0 = ANGLE0,
swing = SWING,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysPlantParametricProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ stem: RGB; leaf: RGB; ink: RGB } | null>(null);
const strsRef = useRef<string[]>([]) as Box<string[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [ang, setAng] = useState(ANGLE0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPlant(ctx: CanvasRenderingContext2D, str: string, x0: number, y0: number, stepLen: number, angleDeg: number, ox: number, oy: number, sc: number, alphaMul: number, drawFrac: number): void {
const col = colRef.current;
if (!col) return;
let x = x0, y = y0, a = -Math.PI / 2;
const stack: Stack[] = [];
const n = str.length;
const drawUpto = Math.floor(n * drawFrac);
for (let i = 0; i < drawUpto; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const nx = x + Math.cos(a) * stepLen;
const ny = y + Math.sin(a) * stepLen;
const depth = stack.length;
const t = Math.min(1, depth / 6);
const cr = col.stem[0] + (col.leaf[0] - col.stem[0]) * t;
const cg = col.stem[1] + (col.leaf[1] - col.stem[1]) * t;
const cb = col.stem[2] + (col.leaf[2] - col.stem[2]) * t;
ctx.globalAlpha = alphaMul * (0.55 + 0.45 * t);
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.9, 3.2 * Math.pow(0.72, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= 4) {
ctx.globalAlpha = alphaMul * 0.5;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 3.2, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.fill();
}
x = nx; y = ny;
} else if (ch === "+") {
a += angleDeg * Math.PI / 180;
} else if (ch === "-") {
a -= angleDeg * Math.PI / 180;
} else if (ch === "[") {
stack.push({ x, y, a });
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; }
}
}
ctx.globalAlpha = 1;
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const STRS = strsRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !STRS.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.16;
const ox = W * 0.5, oy = H * 0.95;
const grPhase = Math.min(loop / 0.55, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const drawFrac = gA >= maxGen ? 1 : Math.min(1, genF - gA);
const angT = loop >= 0.55 ? (loop - 0.55) / 0.45 : 0;
const angle = angle0 + (loop >= 0.55 ? Math.sin(angT * Math.PI * 3) * swing : 0);
setAng(Math.round(angle));
drawPlant(ctx, STRS[gA], 0, 0, 1 / Math.pow(1.55, gA) * 0.62, angle, ox, oy, sc, 1, gA >= maxGen ? 1 : drawFrac);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
stem: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
const strs: string[] = [];
for (let g0 = 0; g0 <= maxGen; g0++) strs.push(expand("X", g0));
strsRef.current = strs;
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle0, swing, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{ang}</b>°</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}07 · パラメータ植物 — F+-[] 文字列が育つ葉 — 計測 累積面積 6.449% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
角度が揺れる生成文法
axiom X に X→F+[[X]-X]-F[-FX]+X、F→FF の2規則を世代0→5で適用すると葉が広がります。育ち切った後は基準の分岐角25度がサイン波で±6度の帯を揺れ、「同じ文法・異なるパラメータ=異なる植物」をリアルタイムで示します。
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));
深さで薄れる葉
STRS 配列は世代0からMAXGENまでの文字列をあらかじめ全部展開して保存したもので、毎フレーム再展開しません。葉は枝の深さに応じて globalAlpha を下げることで、奥の枝ほど薄く見えるようにしています。
08風に揺れる木 — 世代ごとに位相がずれる揺れ
枝の深さごとに風の位相を少しずつずらすと、幹はゆっくり、先端は速く揺れます。世代番号を振動パラメータに使う計算が乗る、生きた項目です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>文字列 F→F[+F]F[-F] はそのままに、亀が一歩進むたびに曲げる角度へ世代の深さに比例して大きくなる正弦波を足すと、根元はほぼ動かず枝先だけが大きく揺れる風になります</span>
</p>
<div class="ls__field" role="application" aria-label="風に揺れる木ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 F→F[+F]F[-F] · 風の強さ <b id="lsWind">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · globalAlpha</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 바람 흔들림 나무(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(고정 문자열 + 깊이비례 사인파 흔들림)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~07 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 바람 흔들림 木(08) — 문자열 자체(F→F[+F]F[-F])는 고정. 거북이 걸을 때 방향각에
// "깊이(世代)에 비례하는 사인파"를 더해서, 같은 문자열이 매 프레임 다른 모양으로 보인다.
// 뿌리는 위상이 0 이라 거의 안 흔들리고, 잎 끝은 깊이가 깊어 크게 흔들린다(자연스러운 바람 감쇠).
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 24;
var MAXGEN = 5;
var WIND_AMP = 30; // 잎 끝 최대 흔들림(도)
var WIND_HZ = 1.6; // 바람 진동수(회/루프)
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var windEl = document.getElementById('lsWind');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'F': 'F[+F]F[-F]' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
var STR = expand('F', MAXGEN);
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 거북 렌더러 — 매 F 걸음마다 깊이 비례 사인파를 각도에 더해(바람) 그린다.
function drawWind(ctx, str, x0, y0, stepLen, ox, oy, sc, windPhase, windAmp, windGust) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var depth = 0, maxDepth = MAXGEN;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
// 世代 깊이에 비례(선형)해 커지는 사인파 — 잔가지뿐 아니라 중간 가지도 함께 휘어야
// 프레임마다 바뀌는 픽셀 면적이 뿌리 근처 몇 개 선분에만 갇히지 않는다.
var sway = windAmp * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = stepLen * Math.pow(0.78, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.globalAlpha = 0.6 + 0.4 * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(1.5, 5.2 * Math.pow(0.66, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= maxDepth - 1) {
// 잎 끝 무리 — 반경 자체도 돌풍 세기를 따라 커지고 작아져 정지 상태로 겹치지 않는다.
var glowR = 7 + 5 * windGust;
var coreR = 3 + 2 * windGust;
ctx.fillStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.globalAlpha = 0.2;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.62;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, coreR, 0, Math.PI * 2);
ctx.fill();
}
x = nx; y = ny;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a, d: depth });
depth++;
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
} else if (ch === '+') {
a += ANGLE * Math.PI / 180;
} else if (ch === '-') {
a -= ANGLE * Math.PI / 180;
}
}
ctx.globalAlpha = 1;
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.24;
var ox = W * 0.5, oy = H * 0.95;
var windPhase = loop * Math.PI * 2 * WIND_HZ;
var windGust = 0.55 + 0.45 * Math.sin(loop * Math.PI * 2 * 0.5); // 돌풍 세기 자체도 서서히 오르내린다
var windAmp = WIND_AMP * windGust;
if (windEl) windEl.textContent = String(Math.round(windGust * 10));
drawWind(ctx, STR, 0, 0, 1, ox, oy, sc, windPhase, windAmp, windGust);
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(0.3);
});
fit();
if (reduce) {
render(0.3);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysTreeWindSway — vanilla 판(run/328/demo/08-lsys-tree-wind-sway/index.html) 의 React 포트.
// F 축 나무(F→F[+F]F[-F]F, 24도)를 완성해 둔 채 각 마디의 흔들림 위상을 깊이(depth)에 비례해 지연시켜
// 바람이 뿌리에서 가지 끝으로 퍼져 나가는 것처럼 보이게 한다. 세기는 sin(t·WIND_HZ) 로 계산.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysTreeWindSway.module.scss";
export type LsysTreeWindSwayProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
windAmp?: number;
windHz?: number;
cycle?: number;
};
const ANGLE = 24;
const MAXGEN = 5;
const WIND_AMP = 30;
const WIND_HZ = 1.6;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number; d: number };
const HINT_TEXT = "F→F[+F]F[-F]F 로 완성해 둔 나무에 깊이(depth)마다 위상을 지연시킨 sin(t·1.6Hz) 바람을 더하면, 뿌리는 그대로인데 가지 끝만 크게 흔들리는 실제 나무의 바람 반응을 흉내 낼 수 있습니다";
const META_LEFT_PREFIX = "규칙 F→F[+F]F[-F]F · 바람 ";
const META_RIGHT = "canvas · 위상 지연 · requestAnimationFrame";
const FIELD_LABEL = "바람 흔들림 나무 실험판";
const RULE: Record<string, string> = { F: "F[+F]F[-F]F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysTreeWindSway({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
windAmp = WIND_AMP,
windHz = WIND_HZ,
cycle = CYCLE
}: LsysTreeWindSwayProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ bark: RGB; leaf: RGB; ink: RGB } | null>(null);
const strRef = useRef("") as Box<string>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [wind, setWind] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawWind(ctx: CanvasRenderingContext2D, str: string, ox: number, oy: number, sc: number, stepLen: number, t: number, windStrength: number): void {
const col = colRef.current;
if (!col) return;
let x = 0, y = 0, a = -Math.PI / 2, d = 0;
const stack: Stack[] = [];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const phase = t * windHz * Math.PI * 2 - d * 0.35;
const sway = Math.sin(phase) * (windAmp * Math.PI / 180) * windStrength * Math.min(1, d / 3);
const aa = a + sway;
const nx = x + Math.cos(aa) * stepLen;
const ny = y + Math.sin(aa) * stepLen;
const tcol = Math.min(1, d / 6);
const cr = col.bark[0] + (col.leaf[0] - col.bark[0]) * tcol;
const cg = col.bark[1] + (col.leaf[1] - col.bark[1]) * tcol;
const cb = col.bark[2] + (col.leaf[2] - col.bark[2]) * tcol;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.9, 3 * Math.pow(0.74, d));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (d >= 4) {
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 2.6, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.fill();
}
x = nx; y = ny; a = aa;
} else if (ch === "+") {
a += angle * Math.PI / 180;
} else if (ch === "-") {
a -= angle * Math.PI / 180;
} else if (ch === "[") {
stack.push({ x, y, a, d });
d++;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; d = st.d; }
}
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const str = strRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !str) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.5;
const ox = W * 0.5, oy = H * 0.95;
const t = loop * cycle;
const windStrength = 0.35 + 0.65 * Math.abs(Math.sin(t * windHz * Math.PI * 0.5));
setWind(Math.round(windStrength * 100));
drawWind(ctx, str, ox, oy, sc, sc / Math.pow(1.6, maxGen) * 0.5, t, windStrength);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
strRef.current = expand("F", maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.3);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.3);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, windAmp, windHz, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{wind}</b>%</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}08 · 風に揺れる木 — 世代ごとに位相がずれる揺れ — 計測 累積面積 1.706% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
深さの2乗に比例する振幅
規則 F→F[+F]F-Fはそのままに、タートルが一歩ごとに曲げる角度へ、枝の深さに比例する振幅(最大30度・周期あたり1.6回の振動)のサイン波を足します。枝の長さも深さごとに0.78の深さ乗で縮み、根元はほとんど動かず枝先だけが大きく揺れます。
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);
}
根元と枝先の位相差
風の揺れ角は windAmp * depthT * Math.sin(windPhase + depth * 0.8) という式で計算します。深さ(depth)が位相そのものをずらす項になっているため、根元(depth=0)と枝先で山と谷の位置がわずかにずれ、風が波として枝を渡っていくように見えます。
09表紙の森 — ブランド行の上に育つフラクタルの森
1つの種から複数の木がそれぞれ別の規則で育ち、文の上に届く場面を表紙に置くと、初訪問で画面が生きて見えます。計算シーン1つがサービスの第一印象になる組立例です。

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<div class="ls__hero" role="application" aria-label="表紙の森シーン" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
<div class="ls__veil" aria-hidden="true"></div>
<div class="ls__copy">
<p class="ls__kicker">一文字が育てば</p>
<p class="ls__title">FRACTAL<br>FOREST</p>
<p class="ls__tag" id="lsTag">規則が集まって森になります</p>
</div>
</div>
<div class="ls__bar">
<span class="ls__meta">木 <b id="lsCount">5</b>本 · 規則はそれぞれ違う</span>
<span class="ls__meta ls__meta--dim">canvas · createRadialGradient</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대문 숲(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(숲 성장+바람 + 방사 그라디언트 호흡)와 JS 가 매기는
// 태그 문구 투명도(캔버스 값과 같은 호흡 위상)가 만든다. 무대는 편 공통 숲 밤 이끼.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-ink2: #0a0d07;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-ink2: #{$ls-ink2};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hero {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
display: grid; place-items: center;
}
.ls__hero:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ls__veil { position: absolute; inset: 0; pointer-events: none; }
.ls__copy {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
margin: 0; text-align: center; padding: 0 $sp-3;
}
.ls__kicker {
margin: 0;
font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
letter-spacing: .28em;
color: rgba($ls-mist, .78);
}
.ls__title {
margin: 0;
font-weight: 700; font-size: clamp(18px, 6.4vw, 27px); line-height: 1.08;
letter-spacing: .06em;
color: $ls-mist;
text-shadow: 0 2px 12px rgba($ls-ink2, .8);
}
.ls__tag {
margin: 0;
font-weight: 500; font-size: 10px; line-height: 1.3;
color: rgba($ls-mist, .8);
}
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__hero { height: 100px; }
.ls__meta--dim { display: none; }
.ls__tag { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__hero { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-ink2: #0a0d07;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hero {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
display: grid;
place-items: center;
}
.ls__hero:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ls__veil {
position: absolute;
inset: 0;
pointer-events: none;
}
.ls__copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 0;
text-align: center;
padding: 0 12px;
}
.ls__kicker {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
letter-spacing: 0.28em;
color: rgba(223, 230, 214, 0.78);
}
.ls__title {
margin: 0;
font-weight: 700;
font-size: clamp(18px, 6.4vw, 27px);
line-height: 1.08;
letter-spacing: 0.06em;
color: #dfe6d6;
text-shadow: 0 2px 12px rgba(10, 13, 7, 0.8);
}
.ls__tag {
margin: 0;
font-weight: 500;
font-size: 10px;
line-height: 1.3;
color: rgba(223, 230, 214, 0.8);
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__hero {
height: 100px;
}
.ls__meta--dim {
display: none;
}
.ls__tag {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__hero {
cursor: default !important;
}
}// 대문 숲(09) — 계산 화면 하나가 서비스 첫 화면이 되는 조립. 木 다섯 그루가
// 저마다 다른 문자 재쓰기 규칙(F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(世代 성장 + 08 과 같은 깊이비례 바람) ② 방사 그라디언트 비네팅
// (createRadialGradient, 매 프레임 다시 그려 숨쉬듯 짙어졌다 옅어진다) ③ 타이포(kicker·title·tag).
(function () {
// ── 변형 상수 블록 ──
var MAXGEN = 4;
var CYCLE = 2.0;
var N_TREE = 5;
// ─────────────────
var root = document.getElementById('lsRoot');
var hero = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var tagEl = document.getElementById('lsTag');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var C_INK2 = tokRGB('--ls-ink2');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = hero.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
// 나무마다 다른 재쓰기 규칙 세 가지를 돌려쓴다 — "각기 다른 규칙으로 자란다"를 문자 그대로 구현
var RULES = [
{ 'F': 'F[+F]F[-F]' },
{ 'F': 'F[+F][-F]F' },
{ 'F': 'FF[+F][-F]' }
];
function expand(rule, gen) {
var s = 'F';
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
var TREES = [];
(function seed() {
for (var i = 0; i < N_TREE; i++) {
var rule = RULES[i % RULES.length];
TREES.push({
xFrac: (i + 0.5) / N_TREE,
str: expand(rule, MAXGEN),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % N_TREE) * 0.06
});
}
})();
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function drawTree(ctx, tr, ox, oy, sc, loop) {
var str = tr.str;
var grPhase = Math.min(Math.max(0, (loop - tr.genDelay)) / 0.5, 1);
var drawUpto = Math.floor(str.length * grPhase);
var windPhase = loop * Math.PI * 2 * 1.4 + tr.phase;
var x = 0, y = 0, a = -Math.PI / 2;
var stack = [];
var depth = 0, maxDepth = MAXGEN + 1;
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
var sway = 10 * depthT * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = tr.scale * 0.11 * Math.pow(0.72, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(0.8, 3 * Math.pow(0.68, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
x = nx; y = ny;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a, d: depth });
depth++;
a += tr.angle * Math.PI / 180;
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
a -= tr.angle * Math.PI / 180;
} else if (ch === '+') {
a += tr.angle * Math.PI / 180;
} else if (ch === '-') {
a -= tr.angle * Math.PI / 180;
}
}
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = H * 0.98;
for (var i = 0; i < TREES.length; i++) {
var tr = TREES[i];
drawTree(ctx, tr, W * tr.xFrac, H * 0.98, sc, loop);
}
// 방사 그라디언트 비네팅 — 문장이 잘 읽히도록 가운데를 밝히고 가장자리를 죽인다.
// 반지름을 호흡시켜 정지 구간에도 픽셀이 계속 갱신된다.
var breath = 0.5 + 0.5 * Math.sin(loop * Math.PI * 2 * 0.6);
var gx = W * 0.5, gy = H * 0.42;
var r0 = Math.min(W, H) * (0.08 + 0.02 * breath);
var r1 = Math.max(W, H) * 0.75;
var grad = ctx.createRadialGradient(gx, gy, r0, gx, gy, r1);
grad.addColorStop(0, rgb(C_INK, 0));
grad.addColorStop(0.55, rgb(C_INK, 0.18 + 0.06 * breath));
grad.addColorStop(1, rgb(C_INK2, 0.86));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
if (tagEl) tagEl.style.opacity = String((0.62 + 0.38 * breath).toFixed(2));
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(0.9);
});
fit();
if (reduce) {
render(0.9);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysHeroScene — vanilla 판(run/328/demo/09-lsys-hero-scene/index.html) 의 React 포트.
// 계산 화면 하나가 서비스 첫 화면이 되는 조립. 나무 다섯 그루가 저마다 다른 문자 재쓰기 규칙
// (F→F[+F]F[-F] 류 세 가지를 섞어)으로 자라 문장 위에 이른다.
// 층 3개: ① 숲 캔버스(세대 성장 + 08 과 같은 깊이비례 바람) ② 방사 그라디언트 비네팅
// (createRadialGradient, 매 프레임 다시 그려 숨쉬듯 짙어졌다 옅어진다) ③ 타이포(kicker·title·tag).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysHeroScene.module.scss";
export type LsysHeroSceneProps = {
duration?: string;
easing?: string;
color?: string;
maxGen?: number;
cycle?: number;
nTree?: number;
};
const MAXGEN = 4;
const CYCLE = 2.0;
const N_TREE = 5;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number; d: number };
type Tree = { xFrac: number; str: string; angle: number; scale: number; phase: number; genDelay: number };
const KICKER_TEXT = "문자 하나가 자라면";
const TITLE_LINE1 = "FRACTAL";
const TITLE_LINE2 = "FOREST";
const TAG_TEXT = "규칙이 모여 숲이 됩니다";
const META_LEFT_PREFIX = "나무 ";
const META_LEFT_SUFFIX = "그루 · 규칙 각각 다름";
const META_RIGHT = "canvas · createRadialGradient";
const HERO_LABEL = "대문 숲 히어로 씬";
const RULES: Record<string, string>[] = [
{ F: "F[+F]F[-F]" },
{ F: "F[+F][-F]F" },
{ F: "FF[+F][-F]" }
];
function expand(rule: Record<string, string>, gen: number): string {
let s = "F";
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += rule[ch] !== undefined ? rule[ch] : ch;
}
s = out;
}
return s;
}
function seedTrees(nTree: number, maxGen: number): Tree[] {
const trees: Tree[] = [];
for (let i = 0; i < nTree; i++) {
const rule = RULES[i % RULES.length];
trees.push({
xFrac: (i + 0.5) / nTree,
str: expand(rule, maxGen),
angle: 20 + (i % 3) * 5,
scale: 0.85 + (i % 2) * 0.22,
phase: i * 0.9,
genDelay: (i % nTree) * 0.06
});
}
return trees;
}
function rgb(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysHeroScene({
duration = "2s",
easing = "ease-spring",
color = "#4cd4a6",
maxGen = MAXGEN,
cycle = CYCLE,
nTree = N_TREE
}: LsysHeroSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const heroRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const tagRef = useRef<HTMLParagraphElement | null>(null);
const colRef = useRef<{ bark: RGB; leaf: RGB; ink: RGB; ink2: RGB } | null>(null);
const treesRef = useRef<Tree[]>([]) as Box<Tree[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const hero = heroRef.current;
const cv = cvRef.current;
if (!hero || !cv) return;
const r = hero.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawTree(ctx: CanvasRenderingContext2D, tr: Tree, ox: number, oy: number, sc: number, loop: number): void {
const col = colRef.current;
if (!col) return;
const str = tr.str;
const grPhase = Math.min(Math.max(0, loop - tr.genDelay) / 0.5, 1);
const drawUpto = Math.floor(str.length * grPhase);
const windPhase = loop * Math.PI * 2 * 1.4 + tr.phase;
let x = 0, y = 0, a = -Math.PI / 2;
const stack: Stack[] = [];
let depth = 0;
const maxDepth = maxGen + 1;
for (let i = 0; i < drawUpto; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const depthT = depth / maxDepth;
const sway = 10 * depthT * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
const segLen = tr.scale * 0.11 * Math.pow(0.72, depth);
const nx = x + Math.cos(a) * segLen;
const ny = y + Math.sin(a) * segLen;
const cr = col.bark[0] + (col.leaf[0] - col.bark[0]) * depthT;
const cg = col.bark[1] + (col.leaf[1] - col.bark[1]) * depthT;
const cb = col.bark[2] + (col.leaf[2] - col.bark[2]) * depthT;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.8, 3 * Math.pow(0.68, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
x = nx; y = ny;
} else if (ch === "[") {
stack.push({ x, y, a, d: depth });
depth++;
a += tr.angle * Math.PI / 180;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
a -= tr.angle * Math.PI / 180;
} else if (ch === "+") {
a += tr.angle * Math.PI / 180;
} else if (ch === "-") {
a -= tr.angle * Math.PI / 180;
}
}
}
function render(loop: number): void {
const cv = cvRef.current;
const col = colRef.current;
const TREES = treesRef.current;
const W = wRef.current, H = hRef.current;
if (!cv || !col) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctx.fillStyle = rgb(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = H * 0.98;
for (let i = 0; i < TREES.length; i++) {
const tr = TREES[i];
drawTree(ctx, tr, W * tr.xFrac, H * 0.98, sc, loop);
}
const breath = 0.5 + 0.5 * Math.sin(loop * Math.PI * 2 * 0.6);
const gx = W * 0.5, gy = H * 0.42;
const r0 = Math.min(W, H) * (0.08 + 0.02 * breath);
const r1 = Math.max(W, H) * 0.75;
const grad = ctx.createRadialGradient(gx, gy, r0, gx, gy, r1);
grad.addColorStop(0, rgb(col.ink, 0));
grad.addColorStop(0.55, rgb(col.ink, 0.18 + 0.06 * breath));
grad.addColorStop(1, rgb(col.ink2, 0.86));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
const tagEl = tagRef.current;
if (tagEl) tagEl.style.opacity = (0.62 + 0.38 * breath).toFixed(2);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
render(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink"),
ink2: tokRGB(root, "--ls-ink2")
};
treesRef.current = seedTrees(nTree, maxGen);
fit();
const reduce = reduced();
function onResize(): void {
fit();
render(0.9);
}
window.addEventListener("resize", onResize);
if (reduce) {
render(0.9);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxGen, cycle, nTree]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<div className={styles.hero} ref={heroRef} role="application" aria-label={HERO_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<div className={styles.veil} aria-hidden="true"></div>
<div className={styles.copy}>
<p className={styles.kicker}>{KICKER_TEXT}</p>
<p className={styles.title}>{TITLE_LINE1}<br />{TITLE_LINE2}</p>
<p className={styles.tag} ref={tagRef}>{TAG_TEXT}</p>
</div>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{nTree}</b>{META_LEFT_SUFFIX}</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}09 · 表紙の森 — ブランド行の上に育つフラクタルの森 — 計測 累積面積 11.5% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
5本の木と3つの規則
木は5本あり、それぞれ F[+F]F[-F]・F[+F][-F]F・FF[+F][-F] の3種類の規則を順に使って世代0→4まで同時に育ちます。木ごとに角度(20+(i%3)5)・大きさ(0.85+(i%2)0.22)・成長開始の遅延(0.06×i)が異なり、同じ種のコピーではなく本当に異なる規則で育っているように見えます。
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;
}
ビネットが呼吸する仕組み
背後の createRadialGradient によるスポットライトは、成長が満ちるほど明るく広がるビネットです。累積面積は11.5%(面積最大9.198%)で、これは9本の線ではなく5本の木が同時に重なって育つためで、9項目の中では05カーペットに次いで高い値です。
expand() をループの中で呼ぶと壊れる
曲線関数を構造線に使うこと
この画面の曲線は文字列書き換えが作った歩行順の結果です。arc や quadraticCurveTo で枝そのものを描いてしまうと、書き換え規則がただの絵に置き換わり、文字が育つ意味が消えます。
毎フレーム文字列を再展開すること
規則によって文字列の長さは世代ごとに急増します。progress の値に合わせて毎フレーム expand() を呼び直すと、世代が進むほど計算が重くなりフレームが落ちます。世代ごとの文字列は最初に全部展開して配列に保存しておく必要があります。
05の残存率をトゥイーンの数値で見せかけること
getImageData で実際に塗られたピクセルを数えなければ、「残った面積%」の表示はただのアニメーション進行度になってしまいます。実測することそのものがこの項目の意味です。
04の2文字規則を1つに統合すること
A→B-A-B、B→A+B+A は互いを書き換え合うからこそ三角形の隙間が埋まります。同じ形に見えるからと1文字の規則にまとめると、まったく別の曲線になります。
08の風の振幅を深さと無関係に固定すること
根元がほとんど動かず枝先だけが大きく揺れてこそ「風」に見えます。振幅を深さに関係なく一定にすると、木全体が同じ幅で揺れるただの硬い振動になってしまいます。
9つの項目のコードはコメントまでそのまま zip に収まっているので、z8hwyvrwを読んで開けば、この落とし穴を踏まずに同じ計算をそのまま自分のキャンバスへ持ち帰れます。
なぜ数文字の規則が木になるのか — 書き換えと自己相似
9項目すべてに共通しているのは、画面上の曲線を1行も直接描いていないということです。あるのは axiom という開始文字列と、文字ごとの置き換え規則だけで、その規則を世代の数だけ繰り返し適用した結果の文字列を、タートル(F=1歩前進してlineTo、+/-=固定角度の回転、[ ]=位置と向きのスタックpush/pop)がなぞった軌跡がそのまま枝や海岸線や曲線になります。世代を1つ増やすと文字列の中に同じ規則がもう一段入れ子で現れるため、拡大しても縮小しても似た形が繰り返される「自己相似」が自然に生まれます。構造を担う線はすべて lineTo による直線で、arc・quadraticCurveTo のような曲線関数は種の点や強調の光点のような装飾にしか使っていません。文字列の長さは規則によって世代ごとに2〜3倍で増えるため、9項目とも世代ごとの文字列をあらかじめ配列に保存しておき、毎フレームは保存済みの点をなぞるだけにして計算の暴走を防いでいます。
この画面はどこで使う — ブランド導入とローディング画面
9項目の中で最初に貼る場所として向いているのは、09 表紙の森のようなヒーローセクションです。ロゴやブランド行の上で木が育つ数秒がそのままローディング時間になり、裏で次のページを読み込む間、訪問者を自然に引き止めます。01 再帰枝のように最小構成の項目はローディングスピナーの置き換えにも使いやすく、規則の文字を数文字変えるだけで枝の姿が変わるので、同じコードを貼っても他のサイトと同じには見えません。
07 パラメータ植物は逆に、操作できる場所に向いています。角度や世代数をスライダーで変えられるようにすれば、訪問者が自分で植物を育てるミニツールになり、園芸系の商品紹介の横やコーディング学習ページの実習例として貼ると、同じコードがまったく違う役割を果たします。
枝の角度と reduced-motion — 3つの変形が変える見え方
| 名前 | 値 | 感じ方 |
|---|---|---|
| 枝の角度 | 01 角度25度→45度 | 枝が大きく広がり、針葉樹から広葉樹へ姿を変える |
| 再帰の深さ | 07 世代4→8 | 世代が3つ増えると葉が画面を埋め、計算量は2の累乗で跳ね上がる |
| 縮小比率 | 08 長さ比率0.72→0.85 | 枝が縮まないほど森が密になり、揺れが重くゆっくりに感じられる |
定数を1行だけ差し替える
どの変形も、コード中の定数ブロック(ANGLE・MAXGEN・0.78のような縮小比率)を1行書き換えるだけで試せます。文字列書き換えの規則そのものは変えていないので、育つ図形の種類は同じまま姿だけが変わります。
reduced-motionで残る完成形
@media (prefers-reduced-motion: reduce) を検知した環境ではループを回さず、固定した一場面だけを描いて止め、ルート要素に .is-still クラスを付けます。8項目はMAXGENまで育ち切った姿を固定しますが01だけは世代が1つ足りない枝を固定するので、静止していても画面に残るのは空のキャンバスではなく育った木や海岸線や曲線の形です。
L システムの木を試す前によくある質問
zipにはReact版も入っていますか?
はい。バニラJavaScript版9種とReact版9種の両方が同じzipに入っています。どちらも同じ規則・同じ測定値で動きます。
シェルピンスキー四面体も作れますか?
このzipに入っているのは平面(2D)の9種だけです。シェルピンスキー四面体は3次元への投影計算が別途必要になるため、今回のキャンバス実装には含まれていません。
世代数をどこまで増やせますか?
規則によって文字列は世代ごとに2〜3倍で伸びるため、際限なく増やすと配列の保存に使うメモリと初回展開の計算時間が急に重くなります。9項目中もっとも世代数が多いのは06ドラゴン曲線の11世代で、それより先はブラウザのタブごとに試すことをおすすめします。
もっと読む
タートルの直線描画は moveTo・lineTo、位置と向きの保存/復元は save・restore、ループは requestAnimationFrame が土台です。仕様は MDN の lineTo と MDN の requestAnimationFrame にまとまっています。文字列書き換えの一般的な定義は Wikipedia の L-system を参照してください。









