06Dragon curve — a folding rule that grows a paper dinosaur
Transcribe the fold-a-strip-in-half rule into letters and the paper dragon appears on screen — the flagship application drawn by two symbols rewriting each other.
Best forDecorative panels, game terrain
Math.sinMath.coslineTorequestAnimationFrame

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Repeating the two rules X→X+YF+, Y→-FX-Y on the single line FX grows a dragon curve that folds as densely as a strip of paper folded in half again and again — without ever overlapping itself</span>
</p>
<div class="ls__field" role="application" aria-label="Dragon curve lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule X→X+YF+ · angle 90° · gen <b id="lsGen">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · string rewriting · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 드래곤 커브(06). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자 재쓰기 세대 크로스페이드 + 도는 빛)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~05 와 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark2: #e6d8bc;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark2: #{$ls-bark2};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark2: #e6d8bc;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 드래곤 커브(06) — 종이 접기를 문자로 옮긴 L-시스템(FX 축, X→X+YF+, Y→-FX-Y, 90도).
// 세대가 늘수록 접힌 자국(F 전진, +/- 90도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = gen 크로스페이드 성장(0→11) + 완성 뒤 곡선을 따라 도는 빛 한 점.
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 90;
var MAXGEN = 11;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var genEl = document.getElementById('lsGen');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_LINE = tokRGB('--ls-bark2');
var C_GLOW = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'X': 'X+YF+', 'Y': '-FX-Y' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
// 거북 해석 — F 만 전진(점 추가), X·Y 는 자리표시자(전진 없음), +/- 는 90도 회전.
function turtlePath(str, step) {
var x = 0, y = 0, a = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x: x, y: y });
} else if (ch === '+') {
a += ANGLE * Math.PI / 180;
} else if (ch === '-') {
a -= ANGLE * Math.PI / 180;
}
}
return pts;
}
var AXIOM = 'FX';
var POLY = [];
(function precompute() {
var strs = [];
for (var g = 0; g <= MAXGEN; g++) strs.push(expand(AXIOM, g));
var raw = [];
for (var g2 = 0; g2 <= MAXGEN; g2++) raw.push(turtlePath(strs[g2], 1));
var last = raw[MAXGEN];
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
var span = Math.max(maxX - minX, maxY - minY) || 1;
for (var g3 = 0; g3 <= MAXGEN; g3++) {
var norm = [];
for (var j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
POLY.push(norm);
}
})();
function rgb(c, a) {
return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
}
function drawPath(ctx, pts, ox, oy, sc, col, alpha, lw) {
if (pts.length < 2) return;
ctx.strokeStyle = rgb(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (var i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.8;
var ox = (W - sc) / 2, oy = (H - sc) / 2;
var grPhase = Math.min(loop / 0.7, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var gB = Math.min(MAXGEN, gA + 1);
var f = genF - gA;
drawPath(ctx, POLY[gA], ox, oy, sc, C_LINE, 1, 2.1);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, C_GLOW, f, 1.9);
if (genEl) genEl.textContent = String(gA);
if (loop >= 0.7) {
var t = (loop - 0.7) / 0.3;
var pts = POLY[MAXGEN];
var idxF = t * (pts.length - 1);
var i0 = Math.floor(idxF) % (pts.length - 1);
var lf = idxF - Math.floor(idxF);
var p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
var lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
var ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgb(C_GLOW, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysDragonCurve — vanilla 판(run/328/demo/06-lsys-dragon-curve/index.html) 의 React 포트.
// 종이 접기를 문자로 옮긴 L-시스템(FX 축, X→X+YF+, Y→-FX-Y, 90도).
// 세대가 늘수록 접힌 자국(F 전진, +/- 90도 회전)이 촘촘해지며 자기 자신과 닿되 겹치지 않는다.
// 움직임 = 세대 크로스페이드 성장(0→11) + 완성 뒤 곡선을 따라 도는 빛 한 점.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysDragonCurve.module.scss";
export type LsysDragonCurveProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
cycle?: number;
};
const ANGLE = 90;
const MAXGEN = 11;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = { x: number; y: number };
const HINT_TEXT = "FX 한 줄에서 X→X+YF+, Y→-FX-Y 두 규칙을 되풀이 적용하면 종이를 반으로 접었다 편 자국처럼 겹치지 않고 빼곡히 접히는 용(드래곤) 곡선이 자랍니다";
const META_LEFT_PREFIX = "규칙 X→X+YF+ · 각도 90° · 세대 ";
const META_RIGHT = "canvas · 문자 재쓰기 · requestAnimationFrame";
const FIELD_LABEL = "드래곤 커브 실험판";
const RULE: Record<string, string> = { X: "X+YF+", Y: "-FX-Y" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function turtlePath(str: string, step: number, angle: number): XY[] {
let x = 0, y = 0, a = 0;
const pts: XY[] = [{ x, y }];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
x += Math.cos(a) * step;
y += Math.sin(a) * step;
pts.push({ x, y });
} else if (ch === "+") {
a += angle * Math.PI / 180;
} else if (ch === "-") {
a -= angle * Math.PI / 180;
}
}
return pts;
}
const AXIOM = "FX";
function buildPoly(angle: number, maxGen: number): XY[][] {
const strs: string[] = [];
for (let g = 0; g <= maxGen; g++) strs.push(expand(AXIOM, g));
const raw: XY[][] = [];
for (let g2 = 0; g2 <= maxGen; g2++) raw.push(turtlePath(strs[g2], 1, angle));
const last = raw[maxGen];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < last.length; i++) {
minX = Math.min(minX, last[i].x); maxX = Math.max(maxX, last[i].x);
minY = Math.min(minY, last[i].y); maxY = Math.max(maxY, last[i].y);
}
const span = Math.max(maxX - minX, maxY - minY) || 1;
const poly: XY[][] = [];
for (let g3 = 0; g3 <= maxGen; g3++) {
const norm: XY[] = [];
for (let j = 0; j < raw[g3].length; j++) {
norm.push({ x: (raw[g3][j].x - minX) / span, y: (raw[g3][j].y - minY) / span });
}
poly.push(norm);
}
return poly;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysDragonCurve({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysDragonCurveProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ line: RGB; glow: RGB; ink: RGB } | null>(null);
const polyRef = useRef<XY[][]>([]) as Box<XY[][]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gen, setGen] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawPath(ctx: CanvasRenderingContext2D, pts: XY[], ox: number, oy: number, sc: number, col: RGB, alpha: number, lw: number): void {
if (pts.length < 2) return;
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(ox + pts[0].x * sc, oy + pts[0].y * sc);
for (let i = 1; i < pts.length; i++) ctx.lineTo(ox + pts[i].x * sc, oy + pts[i].y * sc);
ctx.stroke();
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const POLY = polyRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !POLY.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.8;
const ox = (W - sc) / 2, oy = (H - sc) / 2;
const grPhase = Math.min(loop / 0.7, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const gB = Math.min(maxGen, gA + 1);
const f = genF - gA;
drawPath(ctx, POLY[gA], ox, oy, sc, col.line, 1, 2.1);
if (f > 0 && gB !== gA) drawPath(ctx, POLY[gB], ox, oy, sc, col.glow, f, 1.9);
setGen(gA);
if (loop >= 0.7) {
const t = (loop - 0.7) / 0.3;
const pts = POLY[maxGen];
const idxF = t * (pts.length - 1);
const i0 = Math.floor(idxF) % (pts.length - 1);
const lf = idxF - Math.floor(idxF);
const p0 = pts[i0], p1 = pts[i0 + 1] || pts[pts.length - 1];
const lx = ox + (p0.x + (p1.x - p0.x) * lf) * sc;
const ly = oy + (p0.y + (p1.y - p0.y) * lf) * sc;
ctx.fillStyle = rgba(col.glow, 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
line: tokRGB(root, "--ls-bark2"),
glow: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
polyRef.current = buildPoly(angle, maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{gen}</b></span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 Dragon curve — a folding rule that grows a paper dinosaur — Measured area 5.192% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 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 UI03 Standing wave jump-rope harmonicsWave Interference Simulation: 9 Canvas Scenes
Creative UI02 3D rotation — spinning the orbit through oblique projectionLorenz Attractor: 9 Butterfly Chaos Demos