05셸핀스키 카펫 — 칸을 비우며 파고드는 구멍
아홉 칸마다 가운데 칸을 비우는 규칙이 반복되면 천에 구멍이 나 듯 사각형이 뚫리는데, 면이 줄고 가장자리가 늘어나는 프랙탈 차원을 측정으로 보여주는 항목입니다.
쓰임수학 교육, 텍스처 배경
fillRectgetImageDatarequestAnimationFrame

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>문자 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 원작 · zip 에 SCSS·React 포함 · L시스템 프랙탈 나무 9종 — 문자 하나가 가지를 뻗는 캔버스
이 데모가 속한 글
L시스템 프랙탈 나무 9종 — 문자 하나가 가지를 뻗는 캔버스프랙탈 나무는 몇 글자의 문자열을 규칙대로 다시 쓰고 그 결과를 거북이가 걸어 그리는 L시스템 그림입니다. F 하나는 앞으로, + 하나는 회전입니다. 이 몇 글자를 몇 번 다시 읽기만 해도 나뭇가지가 저절로 뻗는데, 이 글의 데모 전부 보기

