02Pythagoras tree — squares upon squares
Rest two squares on a hypotenuse and a barrel-shaped forest grows — a classic from the measured suggestions, revived on canvas.
Best forMath education, decorative panels
Math.sinMath.coslineTofillRect

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


