01Recursive branch — the minimal two-way tree
Draw a trunk, then call yourself twice at shorter length — the tree completes itself, the minimal implementation every fractal tree rule starts from.
Best forIntro explainers, toy widgets
Math.sinMath.coslineTobeginPath

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