Snowflake Generator, 9 Ways (DLA, No Library)
No library, no WebGL — repeat one moment, a wandering particle touching the crystal and sticking, and a six-fold snowflake grows itself, and that's the
01 snowflake-dla-hex-cluster
02 snowflake-dendrite-branch-grow
03 snowflake-needle-fern-star
04 snowflake-six-fold-symmetry-mirror
05 snowflake-frost-window-pane
06 snowflake-crystal-lattice-color
07 snowflake-pointer-seed-grow
08 snowflake-melt-refreeze-cycle
09 snowflake-macro-photo-scene
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 The basic hex cluster — one layer of DLA, random-walk particles sticking where they touch
- 02 Dendrite arms — six tips forking into a dense branching tree
- 03 Needles and ferns — shift the humidity band and ferns turn to needles
- 04 The six-fold mirror — fold by 60 degrees and grow six times faster
- 05 Frosted window — fractal ice creeping inward from the frame edge
- 06 Lattice coloring — etching the age of each cell as pastel rings
- 07 Pointer seeding — a second crystal grows wherever you click
- 08 Melt-and-refreeze — peeling the outermost particles and re-sticking them
- 09 Macro-photo scene — one flake in shallow depth of field on a dark stage
One Sticking Probability, Nine Snowflakes in Order
The nine scenes climb one axis: what decides where a particle sticks. 01 sticks on first contact with STICK=1.0, the plainest possible DLA rule, so the hexagonal outline is the whole demonstration. 02 keeps that same random-walk aggregation but re-rolls the sticking chance by how far the new cell sits from the seed — TIP_BIAS=2.6 turns the blunt cluster into dendrite arms. 03 runs two of 02's crystals side by side and changes nothing but that same bias width, showing the fern-to-needle habit split as one parameter rather than two separate rules. 04 folds the walk itself onto a 60-degree sector instead of biasing where particles land, so the six-fold symmetry comes from geometry, not from six independent simulations. 05 moves the seed from a single center point to the whole window frame and lets a temperature field pull each walker toward the nearest edge — aggregation runs inward instead of outward. 06 changes nothing about how cells stick and instead records when each one stuck, turning that timestamp into growth-ring color. 07 lets a second seed compete with the first at a click, 08 runs a melt-refreeze cycle on a grown crystal, and 09 stages one large crystal like a macro photograph. Read them in order and the ninth scene isn't a new algorithm — it's the same contact rule from item 01, still running, with a camera and a light rig built around it.
Code per item
Each snippet below is lifted verbatim from that item's own script tag, constant names and all, not summarized into pseudocode — drop it into a file and it grows the same crystal the panel above it does.
01The basic hex cluster — one layer of DLA, freeze on contact
Seed the crystal with a small hexagonal cluster at center, launch particles from an outer ring, and let each take a six-direction random walk across a hex grid until it touches the crystal and sticks. No other rule runs, and the hollow concave corners and sharp tips still appear on their own.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>Particles drift in from an outer circle, random-walking until they touch the crystal and freeze — repeat it thousands of times and a spiky hexagon grows on its own from one seed</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="DLA Hexagonal Aggregation Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">0</b>particles aggregated · basic DLA</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 육각 응집(01). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(DLA 랜덤워크 + 잔광 페이드 + 노이즈 진눈깨비)가 만든다.
// 무대는 밤 슬레이트. 파스텔 고정 팔레트를 --flake-* 로 편 공통 규격(01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
// $stage-ink 는 공유 토큰을 그대로 쓰고, 편 무대용 한 단계 톤만 여기서 정의
$flake-stage: #171b24; // 밤 슬레이트 무대 — 어두운 남색빛 (짙어서 파스텔 검사 무관)
@include stage($flake-stage);
$flake-ink: #141821; // 무대 위쪽 — 더 어두운 쪽
$flake-deep: #2b3346; // 무대 아래쪽 — 밤 슬레이트
$flake-haze: #8fa8c9; // 씨앗 주변 헤이즈 — 페이드블루 (채도 .25)
$flake-mote: #f2ded0; // 부유 먼지 — 파스텔 새빛 (거의 백색)
$flake-ice: #fff9f0; // 결정 안쪽 — 파스텔 화이트
$flake-glow: #cfeaff; // 방금 붙은 칸 블룸 — 파스텔 스카이
$flake-lilac: #c9b6ff; // 결정 바깥 — 파스텔 라일락 (채도 .32)
$flake-mint: #c8f4e4; // 활성 입자 — 파스텔 민트
$flake-mist: #d8dee8; // 보조 글자 — 안개 톤
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-deep: #{$flake-deep};
--flake-haze: #{$flake-haze};
--flake-mote: #{$flake-mote};
--flake-ice: #{$flake-ice};
--flake-glow: #{$flake-glow};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist; // 잉크 무대 위 — 안개 톤 글자
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-lilac; font-weight: 700; } // 잉크 위 라일락 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 자라다 멈춘 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #171b24;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141821;
--flake-deep: #2b3346;
--flake-haze: #8fa8c9;
--flake-mote: #f2ded0;
--flake-ice: #fff9f0;
--flake-glow: #cfeaff;
--flake-lilac: #c9b6ff;
--flake-mint: #c8f4e4;
--flake-mist: #d8dee8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dee8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141821;
box-shadow: inset 0 0 0 1px rgba(216, 222, 232, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #c9b6ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 222, 232, 0.72);
}
.cd__val {
color: #c9b6ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 기본 육각 응집(01). DLA(diffusion-limited aggregation) —
// 결정 씨앗 하나를 중앙에 두고, 바깥 링 위의 임의 점에서 입자를 출발시켜
// grid cells 위에서 6방향 무작위 걸음(랜덤워크)을 걷게 한다. 걸음마다 결정에 닿았는지
// 이웃 검사로 확인하고, 닿은 순간 그 칸에 얼어 붙는다(확산이 응집을 제한).
// 이 "닿으면 붙는" 순간만 수천 번 반복하면 구석진 곳엔 입자가 잘 못 들어가
// 안쪽은 비고 팁이 뾰족해진, 육각 양상의 프랙탈 클러스터가 자란다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 결정 레이어 ImageData 를 어둡게 걸어
// 날아간 입자의 궤적이 잔광으로 식며 남게 한다(누적면적 ↑).
// · 배경에는 값노이즈 진눈깨비 밝기 요동과 내리는 눈(눈결정 편의 숨)이
// 매 프레임 캔버스 전체를 움직인다.
// · 최근에 붙은 칸은 붙은 뒤 잠깐 밝게 빛나다 식는(순간 강도 ↑).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var PARTICLES = 34; // 동시에 떠 있는 입자 수 — 응집 속도비례
var STICK = 1.0; // 닿았을 때 붙을 확률(1.0 = 닿는 즉시 응고). 낮추면 뭉툭해진다
var MAX_AGE = 0.55; // 방금 붙은 칸이 빛나는 시간(초) — 크리스털 블룸
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 결정으로 자란다
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x319A01);
// 색은 CSS 커스텀 프로퍼티(--flake-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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_INK = tokRGB('--flake-ink');
var C_DEEP = tokRGB('--flake-deep');
var C_HAZE = tokRGB('--flake-haze');
var C_MOTE = tokRGB('--flake-mote');
var C_ICE = tokRGB('--flake-ice');
var C_GLOW = tokRGB('--flake-glow');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_MIST = tokRGB('--flake-mist');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, t0 = -1;
var bg, data, img2, data2; // 배경 ImageData · 누적(결정+잔광) ImageData
var N = 0; // grid cells 한 변 칸 수
var grid, age; // 응집 칸 플래그 · 붙은 시각
var walk = []; // 현재 랜덤워크 중인 입자
var stuck = 0;
var crystalR = 1; // 결정 반경(칸 수) — 여기 밖에서 입자를 발사한다
var cx = 0, cy = 0; // grid cells 중심
var snow = []; // 내리는 눈 — 캔버스 전체를 살리는 숨
function gridIdx(i, j) { return i * N + j; }
function inGrid(i, j) { return i >= 0 && j >= 0 && i < N && j < N; }
// 6인접 grid cells 이웃. 걸음의 "옆"이 6방향이면 육결정 답게 자란다
var DJ_EVEN = [0, -1, -1, 0, 0, 0];
var DJ_ODD = [0, 0, 0, 0, 1, 1];
var DI = [-1, -1, 0, 1, 0, -1];
function nbr(i, j, k) {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function set(i, j, t) { grid[gridIdx(i, j)] = 1; age[gridIdx(i, j)] = t; }
// ── 씨앗: 작은 육각 무리 — 처음부터 정육각 대칭의 시작점 ──
function plantSeed() {
set(cx, cy, 0); set(cx - 1, cy, 0); set(cx + 1, cy, 0);
set(cx, cy - 1, 0); set(cx, cy + 1, 0);
set(cx - 1, cy - 1, 0); set(cx + 1, cy + 1, 0);
stuck = 7;
}
// ── 입자 하나 발사 — 결정 반경 밖 바깥 링에서 ──
function launch() {
var a = rand() * Math.PI * 2;
var r = crystalR * 1.22 + 3;
var i = cx + Math.round(Math.cos(a) * r);
var j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || grid[gridIdx(i, j)]) return;
walk.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i, j) {
for (var k = 0; k < 6; k++) {
var nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && grid[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
// ── DLA 스텝 — 걷고, 닿으면 붙는다 ──
function step(t) {
for (var s = 0; s < 20; s++) { // 프레임당 스텝 수 — 걸음 속도
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6); // 가끔 방향 재추첨 — 등방성
var nb = nbr(p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; } // 튀어나가면 버리고 다시 발사
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < STICK) { // 닿았다 — 확률로 그 칸에 얼어 붙는다
set(i, j, t);
stuck++;
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > crystalR) crystalR = Math.min(N * 0.46, dc + 2);
}
walk.splice(w, 1);
} else if (Math.hypot(i - cx, j - cy) > crystalR * 1.7 + 8) {
walk.splice(w, 1); // 너무 멀어졌다 — 되돌아올 확률이 얇다
}
}
}
while (walk.length < PARTICLES) launch();
}
// ── 내리는 눈 — 캔버스 전체를 쓸며 내려오는 파스텔 입자 ──
function buildSnow() {
snow = [];
for (var i = 0; i < 42; i++) {
snow.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.1,
v: 0.055 + rand() * 0.11, // 정규화 속도(화면 높이/초)
sway: 0.012 + rand() * 0.03,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9
});
}
}
// ── 값노이즈(부드러운 배경 요동용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function lerp(a, b, k) { return a + (b - a) * k; }
// ── 배경 — 세로 그라디언트 + 노이즈 진눈깨비 + 씨앗 헤이즈 ──
function drawBackdrop(t) {
var pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
var sc = 0.055;
for (var y = 0; y < H; y++) {
var u = y / H;
var r0 = C_INK[0] + (C_DEEP[0] - C_INK[0]) * u;
var g0 = C_INK[1] + (C_DEEP[1] - C_INK[1]) * u;
var b0 = C_INK[2] + (C_DEEP[2] - C_INK[2]) * u;
var dyv = y - pcy;
for (var x = 0; x < W; x++) {
var dxv = x - pcx;
var d = Math.sqrt(dxv * dxv + dyv * dyv);
var haze = Math.exp(-d * d / (W * W * 0.012)) * 30;
var sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 14;
var j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (C_HAZE[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (C_HAZE[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (C_HAZE[2] / 255) + sh * 1.3;
}
}
}
function putc(px, py, c, alpha) {
if (px < 0 || py < 0 || px >= W || py >= H) return;
var j3 = (py * W + px) * 4;
data2[j3] += (c[0] - data2[j3]) * alpha;
data2[j3 + 1] += (c[1] - data2[j3 + 1]) * alpha;
data2[j3 + 2] += (c[2] - data2[j3 + 2]) * alpha;
}
// ── 결정 셀 하나의 색 ──
function cellColor(g, ag) {
var k = Math.min(1, g / (N * 0.38)); // 중심(화이트) → 바깥(라일락)
var base = [
lerp(C_ICE[0], C_LILAC[0], k),
lerp(C_ICE[1], C_LILAC[1], k),
lerp(C_ICE[2], C_LILAC[2], k)
];
if (ag < MAX_AGE) { // 방금 붙은 칸 — 빛나다 식는
var b = Math.pow(1 - ag / MAX_AGE, 1.6);
base[0] += (C_GLOW[0] - base[0]) * (0.85 * b);
base[1] += (C_GLOW[1] - base[1]) * (0.85 * b);
base[2] += (C_GLOW[2] - base[2]) * (0.85 * b);
}
return base;
}
var cellPx = 4;
function draw(t, dt) {
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
// 누적 결정 레이어 — 잔광 페이드(배경 쪽으로 식는다)
var f = 1 - Math.exp(-dt * 4.2);
for (var k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
// 결정 셀 — 씨앗에서의 거리로 착색
var ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
var rr = Math.max(1, Math.round(cellPx * 0.58));
var rr2 = rr * rr;
for (var i = 0; i < N; i++) {
for (var j = 0; j < N; j++) {
var gi = gridIdx(i, j);
if (!grid[gi]) continue;
var col = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
var px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + j * cellPx);
for (var dy = -rr; dy <= rr; dy++) {
for (var dx = -rr; dx <= rr; dx++) {
var dd = dx * dx + dy * dy;
if (dd > rr2) continue;
var px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
var j3 = (py2 * W + px2) * 4;
var a3 = 1 - dd / (rr2 + 1);
data2[j3] += (col[0] - data2[j3]) * a3;
data2[j3 + 1] += (col[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (col[2] - data2[j3 + 2]) * a3;
}
}
}
}
// 활성 입자 — 지금 걷고 있는 입자. 지나간 자리에 잔광이 남는다
for (var wi = 0; wi < walk.length; wi++) {
var p = walk[wi];
var px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + p[1] * cellPx);
var tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, C_MINT, 0.95);
putc(px + 1, py, C_MINT, 0.5 * tw);
putc(px - 1, py, C_MINT, 0.5 * tw);
putc(px, py + 1, C_MINT, 0.5 * tw);
putc(px, py - 1, C_MINT, 0.9 * tw);
}
// 내리는 눈 — 캔버스 전체를 쓸며 내려오는 파스텔 입자
for (var si = 0; si < snow.length; si++) {
var s = snow[si];
var sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
var sy = ((s.y + s.v * t) % 1.15 - 0.07) * H;
var br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
var a4 = 0.55 + 0.4 * br;
var sr = Math.ceil(s.r);
for (var dy2 = -sr; dy2 <= sr; dy2++) {
for (var dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, C_MOTE, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
// ── 캔버스 크기 측정. 칸 수는 화면에 맞춰 홀수로 ──
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
ctx = cv.getContext('2d', { alpha: false });
cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
grid = new Uint8Array(N * N);
age = new Float32Array(N * N);
walk = []; snow = [];
stuck = 0; crystalR = 1;
cx = N >> 1; cy = N >> 1;
bg = ctx.createImageData(W, H); data = bg.data;
img2 = ctx.createImageData(W, H); data2 = img2.data;
for (var k = 3; k < data.length; k += 4) { data[k] = 255; data2[k] = 255; }
plantSeed();
buildSnow();
}
function stillFrame() {
// reduce — 결정이 자라다 멈춘 한 장
for (var k = 0; k < 240; k++) step(0.01 * k);
draw(2.4, 0.016);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(t);
draw(t, dt);
if (nOut) nOut.textContent = String(stuck);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// DlaHexCluster — vanilla 판(run/319/demo/01-snowflake-dla-hex-cluster/index.html) 의 React 포트.
// 기본 육각 응집 — DLA(diffusion-limited aggregation). 씨앗 하나를 중앙에 두고 바깥 링에서
// 입자를 발사해 6방향 랜덤워크를 걷게 하고, 결정에 닿으면 그 칸에 얼어 붙는다. 이 규칙만
// 수천 번 반복하면 육각 팁이 뾰족한 프랙탈 클러스터가 저절로 자란다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DlaHexCluster.module.scss";
export type DlaHexClusterProps = {
duration?: string;
easing?: string;
color?: string;
particles?: number; // 동시에 떠 있는 입자 수 — 응집 속도
stick?: number; // 닿았을 때 붙을 확률(1.0 = 즉시 응고)
maxAge?: number; // 방금 붙은 칸이 빛나는 시간(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Particle = [number, number, number, number];
type Snow = { x: number; y: number; r: number; v: number; sway: number; ph: number; tw: number };
type Colors = { ink: RGB; deep: RGB; haze: RGB; mote: RGB; ice: RGB; glow: RGB; lilac: RGB; mint: RGB; mist: RGB };
const FIELD_LABEL = "DLA 육각 응집 실험판";
const DI = [-1, -1, 0, 1, 0, -1];
const DJ_EVEN = [0, -1, -1, 0, 0, 0];
const DJ_ODD = [0, 0, 0, 0, 1, 1];
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function DlaHexCluster({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c9b6ff",
particles = 34,
stick = 1.0,
maxAge = 0.55,
}: DlaHexClusterProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const particlesRef = useRef(particles) as Box<number>;
const stickRef = useRef(stick) as Box<number>;
const maxAgeRef = useRef(maxAge) as Box<number>;
const randRef = useRef(makePRNG(0x319a01)) as Box<() => number>;
const gridRef = useRef<Uint8Array>(new Uint8Array(0)) as Box<Uint8Array>;
const ageRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const nRef = useRef(1) as Box<number>;
const cellPxRef = useRef(4) as Box<number>;
const centerRef = useRef({ cx: 0, cy: 0 }) as Box<{ cx: number; cy: number }>;
const rRef = useRef(1) as Box<number>;
const stuckRef = useRef(0) as Box<number>;
const walkRef = useRef<Particle[]>([]) as Box<Particle[]>;
const snowRef = useRef<Snow[]>([]) as Box<Snow[]>;
const bgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const img2Ref = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 gridIdx(i: number, j: number): number { return i * nRef.current + j; }
function inGrid(i: number, j: number): boolean {
const N = nRef.current;
return i >= 0 && j >= 0 && i < N && j < N;
}
function nbr(i: number, j: number, k: number): [number, number] {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function setCell(i: number, j: number, t: number): void {
gridRef.current[gridIdx(i, j)] = 1;
ageRef.current[gridIdx(i, j)] = t;
}
function plantSeed(): void {
const { cx, cy } = centerRef.current;
setCell(cx, cy, 0); setCell(cx - 1, cy, 0); setCell(cx + 1, cy, 0);
setCell(cx, cy - 1, 0); setCell(cx, cy + 1, 0);
setCell(cx - 1, cy - 1, 0); setCell(cx + 1, cy + 1, 0);
stuckRef.current = 7;
}
function launch(): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
const a = rand() * Math.PI * 2;
const r = rRef.current * 1.22 + 3;
const i = cx + Math.round(Math.cos(a) * r);
const j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || gridRef.current[gridIdx(i, j)]) return;
walkRef.current.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i: number, j: number): boolean {
for (let k = 0; k < 6; k++) {
const nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && gridRef.current[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
function step(t: number): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
for (let s = 0; s < 20; s++) {
const walk = walkRef.current;
for (let w = walk.length - 1; w >= 0; w--) {
const p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
const nb = nbr(p[0], p[1], p[2]);
const i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; }
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < stickRef.current) {
setCell(i, j, t);
stuckRef.current++;
const dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > rRef.current) rRef.current = Math.min(nRef.current * 0.46, dc + 2);
}
walk.splice(w, 1);
} else if (Math.hypot(i - cx, j - cy) > rRef.current * 1.7 + 8) {
walk.splice(w, 1);
}
}
while (walkRef.current.length < particlesRef.current) launch();
}
}
function buildSnow(): void {
const rand = randRef.current;
const arr: Snow[] = [];
for (let i = 0; i < 42; i++) {
arr.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.1,
v: 0.055 + rand() * 0.11,
sway: 0.012 + rand() * 0.03,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9,
});
}
snowRef.current = arr;
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function drawBackdrop(t: number): void {
const col = colRef.current;
const bg = bgRef.current;
if (!col || !bg) return;
const { w: W, h: H } = sizeRef.current;
const data = bg.data;
const { cx, cy } = centerRef.current;
const cellPx = cellPxRef.current;
const pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
const sc = 0.055;
for (let y = 0; y < H; y++) {
const u = y / H;
const r0 = col.ink[0] + (col.deep[0] - col.ink[0]) * u;
const g0 = col.ink[1] + (col.deep[1] - col.ink[1]) * u;
const b0 = col.ink[2] + (col.deep[2] - col.ink[2]) * u;
const dyv = y - pcy;
for (let x = 0; x < W; x++) {
const dxv = x - pcx;
const d = Math.sqrt(dxv * dxv + dyv * dyv);
const haze = Math.exp(-d * d / (W * W * 0.012)) * 30;
const sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 14;
const j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (col.haze[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (col.haze[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (col.haze[2] / 255) + sh * 1.3;
}
}
}
function putc(px: number, py: number, c: RGB, alpha: number): void {
const { w: W, h: H } = sizeRef.current;
const img2 = img2Ref.current;
if (!img2 || px < 0 || py < 0 || px >= W || py >= H) return;
const data = img2.data;
const j3 = (py * W + px) * 4;
data[j3] += (c[0] - data[j3]) * alpha;
data[j3 + 1] += (c[1] - data[j3 + 1]) * alpha;
data[j3 + 2] += (c[2] - data[j3 + 2]) * alpha;
}
function cellColor(g: number, ag: number): RGB {
const col = colRef.current!;
const N = nRef.current;
const k = Math.min(1, g / (N * 0.38));
const base: RGB = [
lerp(col.ice[0], col.lilac[0], k),
lerp(col.ice[1], col.lilac[1], k),
lerp(col.ice[2], col.lilac[2], k),
];
if (ag < maxAgeRef.current) {
const b = Math.pow(1 - ag / maxAgeRef.current, 1.6);
base[0] += (col.glow[0] - base[0]) * (0.85 * b);
base[1] += (col.glow[1] - base[1]) * (0.85 * b);
base[2] += (col.glow[2] - base[2]) * (0.85 * b);
}
return base;
}
function draw(t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bg = bgRef.current;
const img2 = img2Ref.current;
if (!ctx || !col || !bg || !img2) return;
const { w: W, h: H } = sizeRef.current;
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
const data = bg.data, data2 = img2.data;
const f = 1 - Math.exp(-dt * 4.2);
for (let k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
const N = nRef.current;
const cellPx = cellPxRef.current;
const { cx, cy } = centerRef.current;
const ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
const rr = Math.max(1, Math.round(cellPx * 0.58));
const rr2 = rr * rr;
const grid = gridRef.current, age = ageRef.current;
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
const gi = gridIdx(i, j);
if (!grid[gi]) continue;
const colr = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
const px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + j * cellPx);
for (let dy = -rr; dy <= rr; dy++) {
for (let dx = -rr; dx <= rr; dx++) {
const dd = dx * dx + dy * dy;
if (dd > rr2) continue;
const px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
const j3 = (py2 * W + px2) * 4;
const a3 = 1 - dd / (rr2 + 1);
data2[j3] += (colr[0] - data2[j3]) * a3;
data2[j3 + 1] += (colr[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (colr[2] - data2[j3 + 2]) * a3;
}
}
}
}
const walk = walkRef.current;
for (let wi = 0; wi < walk.length; wi++) {
const p = walk[wi];
const px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + p[1] * cellPx);
const tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, col.mint, 0.95);
putc(px + 1, py, col.mint, 0.5 * tw);
putc(px - 1, py, col.mint, 0.5 * tw);
putc(px, py + 1, col.mint, 0.5 * tw);
putc(px, py - 1, col.mint, 0.9 * tw);
}
const snow = snowRef.current;
for (let si = 0; si < snow.length; si++) {
const s = snow[si];
const sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
const sy = (((s.y + s.v * t) % 1.15) - 0.07) * H;
const br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
const a4 = 0.55 + 0.4 * br;
const sr = Math.ceil(s.r);
for (let dy2 = -sr; dy2 <= sr; dy2++) {
for (let dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, col.mote, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
cellPxRef.current = cellPx;
let N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
nRef.current = N;
gridRef.current = new Uint8Array(N * N);
ageRef.current = new Float32Array(N * N);
walkRef.current = [];
stuckRef.current = 0;
rRef.current = 1;
const cx = N >> 1, cy = N >> 1;
centerRef.current = { cx, cy };
const bg = ctx.createImageData(W, H);
const img2 = ctx.createImageData(W, H);
for (let k = 3; k < bg.data.length; k += 4) { bg.data[k] = 255; img2.data[k] = 255; }
bgRef.current = bg;
img2Ref.current = img2;
plantSeed();
buildSnow();
}
function stillFrame(): void {
for (let k = 0; k < 240; k++) step(0.01 * k);
draw(2.4, 0.016);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
step(t);
draw(t, dt);
setN(String(stuckRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
particlesRef.current = particles;
stickRef.current = stick;
maxAgeRef.current = maxAge;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
deep: tokRGB(root, "--flake-deep"),
haze: tokRGB(root, "--flake-haze"),
mote: tokRGB(root, "--flake-mote"),
ice: tokRGB(root, "--flake-ice"),
glow: tokRGB(root, "--flake-glow"),
lilac: tokRGB(root, "--flake-lilac"),
mint: tokRGB(root, "--flake-mint"),
mist: tokRGB(root, "--flake-mist"),
};
randRef.current = makePRNG(0x319a01);
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
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
}, [particles, stick, maxAge]);
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>바깥 원에서 날아든 입자가 위·아래·옆으로 무작위 걸음을 걷다가 결정에 닿는 순간 그대로 얼어붙는 규칙을 수천 번 반복하면, 씨앗 하나에서 육각 뾰족 클러스터가 저절로 자랍니다</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 className={styles.val}>{n}</b>개 응집 · 기본 DLA</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · The basic hex cluster — one layer of DLA, random-walk particles sticking where they touch — Measured area 43.511% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
STICK=1.0 is the entire shape-making rule
function step(t) {
for (var s = 0; s < 20; s++) { // steps per frame — walk speed
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6); // occasional direction re-roll — isotropy
var nb = nbr(p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; } // wandered off-grid — drop and relaunch
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < STICK) { // touched — freeze into that cell with probability STICK
set(i, j, t);
stuck++;
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > crystalR) crystalR = Math.min(N * 0.46, dc + 2);
}
walk.splice(w, 1);
} else if (Math.hypot(i - cx, j - cy) > crystalR * 1.7 + 8) {
walk.splice(w, 1); // drifted too far — unlikely to come back
}
}
}
02Dendrite branches — six tips forking into a dense tree
Bias the sticking probability toward whichever cell sits farthest from the seed and a blunt blob stops being blunt — thin, dense branches reach outward the way real snowflake dendrites do, a simplified stand-in for the Mullins-Sekerka instability.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>Bias the sticking odds toward the outermost tips, and instead of a blunt lump you get a real snow crystal's tightly branching arms (dendrites)</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Dendrite Crystal DLA Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">0</b>particles aggregated · tip-weighted branches</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 가지 결정(dendrite, 02). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(팁 가중 DLA + 잔광 페이드 + 노이즈 진눈깨비)가 만든다.
// 01 과 같은 파스텔 팔레트를 --flake-* 로 공유(편 공통 규격, 01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
$flake-stage: #171b24;
@include stage($flake-stage);
$flake-ink: #141821;
$flake-deep: #2b3346;
$flake-haze: #8fa8c9;
$flake-mote: #f2ded0;
$flake-ice: #fff9f0;
$flake-glow: #cfeaff;
$flake-lilac: #c9b6ff;
$flake-mint: #c8f4e4;
$flake-mist: #d8dee8;
.cd {
--flake-ink: #{$flake-ink};
--flake-deep: #{$flake-deep};
--flake-haze: #{$flake-haze};
--flake-mote: #{$flake-mote};
--flake-ice: #{$flake-ice};
--flake-glow: #{$flake-glow};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-lilac; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #171b24;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141821;
--flake-deep: #2b3346;
--flake-haze: #8fa8c9;
--flake-mote: #f2ded0;
--flake-ice: #fff9f0;
--flake-glow: #cfeaff;
--flake-lilac: #c9b6ff;
--flake-mint: #c8f4e4;
--flake-mist: #d8dee8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dee8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141821;
box-shadow: inset 0 0 0 1px rgba(216, 222, 232, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #c9b6ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 222, 232, 0.72);
}
.cd__val {
color: #c9b6ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 가지 결정(02). 01 과 같은 DLA 랜덤워크 응집 규칙에 한 겹만 더한다 —
// 결정에 새 칸이 붙을 때, 그 칸이 씨앗에서 얼마나 먼지(반경)로 흡착 여부를 다시 굴린다.
// 반경이 클수록(팁에 가까울수록) 흡착 확률이 높아지도록 편향(TIP_BIAS)을 주면,
// 안쪽 오목한 자리보다 바깥으로 뻗은 팁이 입자를 훨씬 잘 붙잡아 뭉툭한 덩어리 대신
// 가늘고 촘촘한 나뭇가지(수지상) 형태로 자란다 — 이것이 실제 눈결정이 팔각이 아니라
// 가지 모양으로 자라는 이유(팁 쪽 확산 flux 가 더 높다, Mullins-Sekerka 불안정성의 단순화판)다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
// 2026-09-15 재튜닝(BD3 누적면적 28.5%→30%+): 정체성(팁 가중 단일 씨앗 덴드라이트)은 그대로,
// 동시 성장 팁 수·최종 결정 크기·배경 잔광/강설 밀도만 올려 필드 안에서 바뀌는 픽셀 영역을 넓힌다.
var PARTICLES = 56; // 동시에 떠 있는 입자 수 — 응집 속도(42→56, 가지가 더 빨리 더 많이 뻗는다)
var TIP_BIAS = 2.6; // 팁 편향 지수 — 클수록 가지가 가늘고 뾰족해진다(0 이면 01 과 동일)
var MAX_AGE = 0.55; // 방금 붙은 칸이 빛나는 시간(초)
var SNOW = 130; // 내리는 눈 입자 수 — 캔버스 전체를 쓸며 내려온다(90→130)
var R_CAP = 0.48; // 결정 최대 반경 비율(N 대비, 0.46→0.48) — 필드 안에서 더 크게 자란다
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('cdN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x319A02);
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_INK = tokRGB('--flake-ink');
var C_DEEP = tokRGB('--flake-deep');
var C_HAZE = tokRGB('--flake-haze');
var C_MOTE = tokRGB('--flake-mote');
var C_ICE = tokRGB('--flake-ice');
var C_GLOW = tokRGB('--flake-glow');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_MIST = tokRGB('--flake-mist');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, t0 = -1;
var bg, data, img2, data2;
var N = 0;
var grid, age, maxR; // maxR = 그 칸이 흡착될 당시 결정 최대 반경(팁 편향 판정용)
var walk = [];
var stuck = 0;
var crystalR = 1;
var cx = 0, cy = 0;
var snow = [];
var cellPx = 4;
function gridIdx(i, j) { return i * N + j; }
function inGrid(i, j) { return i >= 0 && j >= 0 && i < N && j < N; }
var DJ_EVEN = [0, -1, -1, 0, 0, 0];
var DJ_ODD = [0, 0, 0, 0, 1, 1];
var DI = [-1, -1, 0, 1, 0, -1];
function nbr(i, j, k) { return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])]; }
function set(i, j, t) { grid[gridIdx(i, j)] = 1; age[gridIdx(i, j)] = t; }
function plantSeed() {
set(cx, cy, 0); set(cx - 1, cy, 0); set(cx + 1, cy, 0);
set(cx, cy - 1, 0); set(cx, cy + 1, 0);
set(cx - 1, cy - 1, 0); set(cx + 1, cy + 1, 0);
stuck = 7;
}
function launch() {
var a = rand() * Math.PI * 2;
var r = crystalR * 1.22 + 3;
var i = cx + Math.round(Math.cos(a) * r);
var j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || grid[gridIdx(i, j)]) return;
walk.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i, j) {
for (var k = 0; k < 6; k++) {
var nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && grid[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
// ── 팁 가중 흡착 — 반경이 클수록(팁에 가까울수록) 흡착 확률을 높인다 ──
function stickProb(i, j) {
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, crystalR));
return Math.pow(k, 1) * 0.15 + Math.pow(k, TIP_BIAS) * 0.85 + 0.05;
}
function step(t) {
for (var s = 0; s < 26; s++) {
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
var nb = nbr(p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; }
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < stickProb(i, j)) { // 팁 편향 — 안쪽 오목한 자리는 잘 안 붙는다
set(i, j, t);
stuck++;
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > crystalR) crystalR = Math.min(N * R_CAP, dc + 2);
}
// 안 붙으면 계속 걷는다(제자리 유지) — 오목한 자리에서 자꾸 튕겨나가며 팁만 자란다
} else if (Math.hypot(i - cx, j - cy) > crystalR * 1.7 + 8) {
walk.splice(w, 1);
}
}
}
while (walk.length < PARTICLES) launch();
}
function buildSnow() {
snow = [];
for (var i = 0; i < SNOW; i++) {
snow.push({
x: rand(), y: rand(),
r: 1.2 + rand() * 2.4,
v: 0.07 + rand() * 0.14,
sway: 0.014 + rand() * 0.034,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9
});
}
}
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function lerp(a, b, k) { return a + (b - a) * k; }
function drawBackdrop(t) {
var pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
var sc = 0.055;
for (var y = 0; y < H; y++) {
var u = y / H;
var r0 = C_INK[0] + (C_DEEP[0] - C_INK[0]) * u;
var g0 = C_INK[1] + (C_DEEP[1] - C_INK[1]) * u;
var b0 = C_INK[2] + (C_DEEP[2] - C_INK[2]) * u;
var dyv = y - pcy;
for (var x = 0; x < W; x++) {
var dxv = x - pcx;
var d = Math.sqrt(dxv * dxv + dyv * dyv);
var haze = Math.exp(-d * d / (W * W * 0.018)) * 34;
var sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 28;
var j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (C_HAZE[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (C_HAZE[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (C_HAZE[2] / 255) + sh * 1.3;
}
}
}
function putc(px, py, c, alpha) {
if (px < 0 || py < 0 || px >= W || py >= H) return;
var j3 = (py * W + px) * 4;
data2[j3] += (c[0] - data2[j3]) * alpha;
data2[j3 + 1] += (c[1] - data2[j3 + 1]) * alpha;
data2[j3 + 2] += (c[2] - data2[j3 + 2]) * alpha;
}
function cellColor(g, ag) {
var k = Math.min(1, g / (N * 0.38));
var base = [
lerp(C_ICE[0], C_LILAC[0], k),
lerp(C_ICE[1], C_LILAC[1], k),
lerp(C_ICE[2], C_LILAC[2], k)
];
if (ag < MAX_AGE) {
var b = Math.pow(1 - ag / MAX_AGE, 1.6);
base[0] += (C_GLOW[0] - base[0]) * (0.85 * b);
base[1] += (C_GLOW[1] - base[1]) * (0.85 * b);
base[2] += (C_GLOW[2] - base[2]) * (0.85 * b);
}
return base;
}
function draw(t, dt) {
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
var f = 1 - Math.exp(-dt * 4.2);
for (var k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data2[k + 2] - data2[k + 2]) * f;
}
var ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
var rr = Math.max(1, Math.round(cellPx * 0.58));
var rr2 = rr * rr;
for (var i = 0; i < N; i++) {
for (var j = 0; j < N; j++) {
var gi = gridIdx(i, j);
if (!grid[gi]) continue;
var col = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
var px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + j * cellPx);
for (var dy = -rr; dy <= rr; dy++) {
for (var dx = -rr; dx <= rr; dx++) {
var dd = dx * dx + dy * dy;
if (dd > rr2) continue;
var px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
var j3 = (py2 * W + px2) * 4;
var a3 = 1 - dd / (rr2 + 1);
data2[j3] += (col[0] - data2[j3]) * a3;
data2[j3 + 1] += (col[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (col[2] - data2[j3 + 2]) * a3;
}
}
}
}
for (var wi = 0; wi < walk.length; wi++) {
var p = walk[wi];
var px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + p[1] * cellPx);
var tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, C_MINT, 0.95);
putc(px + 1, py, C_MINT, 0.5 * tw);
putc(px - 1, py, C_MINT, 0.5 * tw);
putc(px, py + 1, C_MINT, 0.5 * tw);
putc(px, py - 1, C_MINT, 0.9 * tw);
}
for (var si = 0; si < snow.length; si++) {
var s = snow[si];
var sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
var sy = ((s.y + s.v * t) % 1.15 - 0.07) * H;
var br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
var a4 = 0.55 + 0.4 * br;
var sr = Math.ceil(s.r);
for (var dy2 = -sr; dy2 <= sr; dy2++) {
for (var dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, C_MOTE, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
ctx = cv.getContext('2d', { alpha: false });
cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
grid = new Uint8Array(N * N);
age = new Float32Array(N * N);
walk = []; snow = [];
stuck = 0; crystalR = 1;
cx = N >> 1; cy = N >> 1;
bg = ctx.createImageData(W, H); data = bg.data;
img2 = ctx.createImageData(W, H); data2 = img2.data;
for (var k = 3; k < data.length; k += 4) { data[k] = 255; data2[k] = 255; }
plantSeed();
buildSnow();
}
function stillFrame() {
for (var k = 0; k < 260; k++) step(0.01 * k);
draw(2.6, 0.016);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(t);
draw(t, dt);
if (nOut) nOut.textContent = String(stuck);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// DendriteBranchGrow — vanilla 판(run/319/demo/02-snowflake-dendrite-branch-grow/index.html) 의 React 포트.
// 가지 결정(dendrite) — DLA 에 팁 편향 한 겹. 결정에 닿은 입자가 그 칸이 씨앗에서 먼 정도에
// 비례해 붙을 확률을 받는다(팁일수록 잘 붙는다). 안쪽 오목한 자리는 잘 안 붙어 뭉툭한 덩어리 대신
// 가늘고 촘촘한 가지(수지상)가 뻗는다 — Mullins-Sekerka 불안정성의 단순화판.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DendriteBranchGrow.module.scss";
export type DendriteBranchGrowProps = {
duration?: string;
easing?: string;
color?: string;
particles?: number; // 동시에 떠 있는 입자 수 — 응집 속도
tipBias?: number; // 팁 편향 지수 — 클수록 가지가 가늘고 뾰족(0 이면 기본 DLA)
maxAge?: number; // 방금 붙은 칸이 빛나는 시간(초)
snowCount?: number; // 내리는 눈 입자 수 — 배경 강설 밀도
radiusCap?: number; // 결정 최대 반경 비율(N 대비) — 필드 안에서 자랄 수 있는 한계
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Particle = [number, number, number, number];
type Snow = { x: number; y: number; r: number; v: number; sway: number; ph: number; tw: number };
type Colors = { ink: RGB; deep: RGB; haze: RGB; mote: RGB; ice: RGB; glow: RGB; lilac: RGB; mint: RGB; mist: RGB };
const FIELD_LABEL = "가지 결정 DLA 실험판";
const DI = [-1, -1, 0, 1, 0, -1];
const DJ_EVEN = [0, -1, -1, 0, 0, 0];
const DJ_ODD = [0, 0, 0, 0, 1, 1];
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function DendriteBranchGrow({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c9b6ff",
particles = 56,
tipBias = 2.6,
maxAge = 0.55,
snowCount = 130,
radiusCap = 0.48,
}: DendriteBranchGrowProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const particlesRef = useRef(particles) as Box<number>;
const tipBiasRef = useRef(tipBias) as Box<number>;
const maxAgeRef = useRef(maxAge) as Box<number>;
const snowCountRef = useRef(snowCount) as Box<number>;
const radiusCapRef = useRef(radiusCap) as Box<number>;
const randRef = useRef(makePRNG(0x319a02)) as Box<() => number>;
const gridRef = useRef<Uint8Array>(new Uint8Array(0)) as Box<Uint8Array>;
const ageRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const nRef = useRef(1) as Box<number>;
const cellPxRef = useRef(4) as Box<number>;
const centerRef = useRef({ cx: 0, cy: 0 }) as Box<{ cx: number; cy: number }>;
const rRef = useRef(1) as Box<number>;
const stuckRef = useRef(0) as Box<number>;
const walkRef = useRef<Particle[]>([]) as Box<Particle[]>;
const snowRef = useRef<Snow[]>([]) as Box<Snow[]>;
const bgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const img2Ref = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
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 gridIdx(i: number, j: number): number { return i * nRef.current + j; }
function inGrid(i: number, j: number): boolean {
const N = nRef.current;
return i >= 0 && j >= 0 && i < N && j < N;
}
function nbr(i: number, j: number, k: number): [number, number] {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function setCell(i: number, j: number, t: number): void {
gridRef.current[gridIdx(i, j)] = 1;
ageRef.current[gridIdx(i, j)] = t;
}
function plantSeed(): void {
const { cx, cy } = centerRef.current;
setCell(cx, cy, 0); setCell(cx - 1, cy, 0); setCell(cx + 1, cy, 0);
setCell(cx, cy - 1, 0); setCell(cx, cy + 1, 0);
setCell(cx - 1, cy - 1, 0); setCell(cx + 1, cy + 1, 0);
stuckRef.current = 7;
}
function launch(): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
const a = rand() * Math.PI * 2;
const r = rRef.current * 1.22 + 3;
const i = cx + Math.round(Math.cos(a) * r);
const j = cy + Math.round(Math.sin(a) * r);
if (!inGrid(i, j) || gridRef.current[gridIdx(i, j)]) return;
walkRef.current.push([i, j, Math.floor(rand() * 6), rand() * Math.PI * 2]);
}
function touching(i: number, j: number): boolean {
for (let k = 0; k < 6; k++) {
const nb = nbr(i, j, k);
if (inGrid(nb[0], nb[1]) && gridRef.current[gridIdx(nb[0], nb[1])]) return true;
}
return false;
}
// 팁 가중 흡착 — 반경이 클수록(팁에 가까울수록) 흡착 확률을 높인다
function stickProb(i: number, j: number): number {
const { cx, cy } = centerRef.current;
const dc = Math.hypot(i - cx, (j - cy) * 0.92);
const k = Math.min(1, dc / Math.max(1, rRef.current));
return Math.pow(k, 1) * 0.15 + Math.pow(k, tipBiasRef.current) * 0.85 + 0.05;
}
function step(t: number): void {
const rand = randRef.current;
const { cx, cy } = centerRef.current;
for (let s = 0; s < 26; s++) {
const walk = walkRef.current;
for (let w = walk.length - 1; w >= 0; w--) {
const p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
const nb = nbr(p[0], p[1], p[2]);
const i = nb[0], j = nb[1];
if (!inGrid(i, j)) { walk.splice(w, 1); continue; }
if (rand() < 0.2) p[3] += (rand() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(i, j)) {
if (rand() < stickProb(i, j)) {
setCell(i, j, t);
stuckRef.current++;
const dc = Math.hypot(i - cx, (j - cy) * 0.92);
if (dc + 2 > rRef.current) rRef.current = Math.min(nRef.current * radiusCapRef.current, dc + 2);
}
} else if (Math.hypot(i - cx, j - cy) > rRef.current * 1.7 + 8) {
walk.splice(w, 1);
}
}
while (walkRef.current.length < particlesRef.current) launch();
}
}
function buildSnow(): void {
const rand = randRef.current;
const arr: Snow[] = [];
for (let i = 0; i < snowCountRef.current; i++) {
arr.push({
x: rand(), y: rand(),
r: 1.2 + rand() * 2.4,
v: 0.07 + rand() * 0.14,
sway: 0.014 + rand() * 0.034,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9,
});
}
snowRef.current = arr;
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function drawBackdrop(t: number): void {
const col = colRef.current;
const bg = bgRef.current;
if (!col || !bg) return;
const { w: W, h: H } = sizeRef.current;
const data = bg.data;
const { cx, cy } = centerRef.current;
const cellPx = cellPxRef.current;
const pcx = (cx + 0.5) * cellPx, pcy = (cy + 0.5) * cellPx;
const sc = 0.055;
for (let y = 0; y < H; y++) {
const u = y / H;
const r0 = col.ink[0] + (col.deep[0] - col.ink[0]) * u;
const g0 = col.ink[1] + (col.deep[1] - col.ink[1]) * u;
const b0 = col.ink[2] + (col.deep[2] - col.ink[2]) * u;
const dyv = y - pcy;
for (let x = 0; x < W; x++) {
const dxv = x - pcx;
const d = Math.sqrt(dxv * dxv + dyv * dyv);
const haze = Math.exp(-d * d / (W * W * 0.018)) * 34;
const sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 28;
const j3 = (y * W + x) * 4;
data[j3] = r0 + haze * (col.haze[0] / 255) + sh;
data[j3 + 1] = g0 + haze * (col.haze[1] / 255) + sh;
data[j3 + 2] = b0 + haze * (col.haze[2] / 255) + sh * 1.3;
}
}
}
function putc(px: number, py: number, c: RGB, alpha: number): void {
const { w: W, h: H } = sizeRef.current;
const img2 = img2Ref.current;
if (!img2 || px < 0 || py < 0 || px >= W || py >= H) return;
const data = img2.data;
const j3 = (py * W + px) * 4;
data[j3] += (c[0] - data[j3]) * alpha;
data[j3 + 1] += (c[1] - data[j3 + 1]) * alpha;
data[j3 + 2] += (c[2] - data[j3 + 2]) * alpha;
}
function cellColor(g: number, ag: number): RGB {
const col = colRef.current!;
const N = nRef.current;
const k = Math.min(1, g / (N * 0.38));
const base: RGB = [
lerp(col.ice[0], col.lilac[0], k),
lerp(col.ice[1], col.lilac[1], k),
lerp(col.ice[2], col.lilac[2], k),
];
if (ag < maxAgeRef.current) {
const b = Math.pow(1 - ag / maxAgeRef.current, 1.6);
base[0] += (col.glow[0] - base[0]) * (0.85 * b);
base[1] += (col.glow[1] - base[1]) * (0.85 * b);
base[2] += (col.glow[2] - base[2]) * (0.85 * b);
}
return base;
}
function draw(t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bg = bgRef.current;
const img2 = img2Ref.current;
if (!ctx || !col || !bg || !img2) return;
const { w: W, h: H } = sizeRef.current;
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
const data = bg.data, data2 = img2.data;
const f = 1 - Math.exp(-dt * 4.2);
for (let k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
const N = nRef.current;
const cellPx = cellPxRef.current;
const { cx, cy } = centerRef.current;
const ox = (W - N * cellPx) / 2, oy = (H - N * cellPx) / 2;
const rr = Math.max(1, Math.round(cellPx * 0.58));
const rr2 = rr * rr;
const grid = gridRef.current, age = ageRef.current;
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
const gi = gridIdx(i, j);
if (!grid[gi]) continue;
const colr = cellColor(Math.hypot(i - cx, (j - cy) * 0.92), t - age[gi]);
const px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + j * cellPx);
for (let dy = -rr; dy <= rr; dy++) {
for (let dx = -rr; dx <= rr; dx++) {
const dd = dx * dx + dy * dy;
if (dd > rr2) continue;
const px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
const j3 = (py2 * W + px2) * 4;
const a3 = 1 - dd / (rr2 + 1);
data2[j3] += (colr[0] - data2[j3]) * a3;
data2[j3 + 1] += (colr[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (colr[2] - data2[j3 + 2]) * a3;
}
}
}
}
const walk = walkRef.current;
for (let wi = 0; wi < walk.length; wi++) {
const p = walk[wi];
const px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + p[1] * cellPx);
const tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, col.mint, 0.95);
putc(px + 1, py, col.mint, 0.5 * tw);
putc(px - 1, py, col.mint, 0.5 * tw);
putc(px, py + 1, col.mint, 0.5 * tw);
putc(px, py - 1, col.mint, 0.9 * tw);
}
const snow = snowRef.current;
for (let si = 0; si < snow.length; si++) {
const s = snow[si];
const sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
const sy = (((s.y + s.v * t) % 1.15) - 0.07) * H;
const br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
const a4 = 0.55 + 0.4 * br;
const sr = Math.ceil(s.r);
for (let dy2 = -sr; dy2 <= sr; dy2++) {
for (let dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, col.mote, a4);
}
}
}
ctx.putImageData(img2, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const cellPx = Math.max(3, Math.min(5, Math.floor(Math.min(W, H) / 42)));
cellPxRef.current = cellPx;
let N = Math.floor(Math.min(W, H) / cellPx);
if ((N & 1) === 0) N -= 1;
nRef.current = N;
gridRef.current = new Uint8Array(N * N);
ageRef.current = new Float32Array(N * N);
walkRef.current = [];
stuckRef.current = 0;
rRef.current = 1;
const cx = N >> 1, cy = N >> 1;
centerRef.current = { cx, cy };
const bg = ctx.createImageData(W, H);
const img2 = ctx.createImageData(W, H);
for (let k = 3; k < bg.data.length; k += 4) { bg.data[k] = 255; img2.data[k] = 255; }
bgRef.current = bg;
img2Ref.current = img2;
plantSeed();
buildSnow();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) step(0.01 * k);
draw(2.6, 0.016);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
step(t);
draw(t, dt);
setN(String(stuckRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
particlesRef.current = particles;
tipBiasRef.current = tipBias;
maxAgeRef.current = maxAge;
snowCountRef.current = snowCount;
radiusCapRef.current = radiusCap;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
deep: tokRGB(root, "--flake-deep"),
haze: tokRGB(root, "--flake-haze"),
mote: tokRGB(root, "--flake-mote"),
ice: tokRGB(root, "--flake-ice"),
glow: tokRGB(root, "--flake-glow"),
lilac: tokRGB(root, "--flake-lilac"),
mint: tokRGB(root, "--flake-mint"),
mist: tokRGB(root, "--flake-mist"),
};
randRef.current = makePRNG(0x319a02);
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
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
}, [particles, tipBias, maxAge, snowCount, radiusCap]);
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>입자가 가장 바깥으로 뻗은 팁에 더 잘 붙도록 흡착 확률을 팁 쪽으로 치우치게 하면, 뭉툭한 덩어리가 아니라 진짜 눈결정의 가지(수지상)가 촘촘히 뻗어갑니다</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 className={styles.val}>{n}</b>개 응집 · 팁 가중 가지</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · Dendrite arms — six tips forking into a dense branching tree — Measured area 44.129% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
TIP_BIAS re-rolls the same contact rule by radius
// ── Tip-weighted sticking — the farther from center (closer to a tip), the higher the odds ──
function stickProb(i, j) {
var dc = Math.hypot(i - cx, (j - cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, crystalR));
return Math.pow(k, 1) * 0.15 + Math.pow(k, TIP_BIAS) * 0.85 + 0.05;
}
function step(t) {
for (var s = 0; s < 26; s++) {
for (var w = walk.length - 1; w >= 0; w--) {
var p = walk[w];
if (rand() < 0.25) p[2] = Math.floor(rand() * 6);
var nb = nbr(p[0], p[1], p[2]);
03Needles and ferns — one bias width splits both habits
Run two crystals side by side that share the exact same tip-weighted aggregation and differ only in how wide that bias curve is — a narrow gap between tip and interior odds fills out into a bushy fern, a wide gap sticks almost only at the tip and grows long needle bundles.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>Change one parameter (branch-width odds) and grow two crystals side by side — a broad fern splits from a narrow needle, showing how snow crystals shift shape with temperature and humidity</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Needle vs. Fern Crystal Comparison Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">left <b class="cd__val" id="cdL">0</b> · right <b class="cd__val cd__val--alt" id="cdR">0</b>particles aggregated</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 침·고사리 결정(03). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(좌우 두 DLA 동시 성장 + 잔광 + 진눈깨비)가 만든다.
// 01·02 와 같은 파스텔 팔레트에 우(침) 결정용 모래빛 한 색만 더한다(--flake-sand).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
$flake-stage: #171b24;
@include stage($flake-stage);
$flake-ink: #141821;
$flake-deep: #2b3346;
$flake-haze: #8fa8c9;
$flake-mote: #f2ded0;
$flake-ice: #fff9f0;
$flake-glow: #cfeaff;
$flake-lilac: #c9b6ff;
$flake-mint: #c8f4e4;
$flake-sand: #e6d9b8; // 우(침) 결정 바깥 — 모래빛 파스텔(채도 .18)
$flake-mist: #d8dee8;
.cd {
--flake-ink: #{$flake-ink};
--flake-deep: #{$flake-deep};
--flake-haze: #{$flake-haze};
--flake-mote: #{$flake-mote};
--flake-ice: #{$flake-ice};
--flake-glow: #{$flake-glow};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-sand: #{$flake-sand};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-lilac; font-weight: 700; }
.cd__val--alt { color: $flake-sand; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #171b24;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141821;
--flake-deep: #2b3346;
--flake-haze: #8fa8c9;
--flake-mote: #f2ded0;
--flake-ice: #fff9f0;
--flake-glow: #cfeaff;
--flake-lilac: #c9b6ff;
--flake-mint: #c8f4e4;
--flake-sand: #e6d9b8;
--flake-mist: #d8dee8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dee8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141821;
box-shadow: inset 0 0 0 1px rgba(216, 222, 232, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #c9b6ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 222, 232, 0.72);
}
.cd__val {
color: #c9b6ff;
font-weight: 700;
}
.cd__val--alt {
color: #e6d9b8;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 침·고사리 결정(03). 02 의 팁 가중 DLA 에서 흡착 확률 곡선의 "폭" 만 바꾸면
// 결정 습관이 갈라진다 —
// · 고사리(fernite, left): 폭 넓게(BIAS 1.6) — 팁과 오목한 자리의 흡착 차가 작아
// 가지가 옆으로도 붙으며 넓은 잎(고사리 무늬)으로 자란다.
// · 침(needle, 우): 폭 좁게(BIAS 4.6) — 바깥 팁만 붙고 안쪽은 전부 튕겨나가
// 가늘고 긴 바늘 다발로 자란다.
// 실제 눈결정의 형성 지도(온도·과포화도에 따라 판·침·고사리가 갈리는 Nakaya 다이어그램)의
// 축 하나를 파라미터 하나로 압축한 대비 시연이다. 두 결정은 같은 배경 위에서 동시에 자란다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var BIAS_FERN = 1.6; // left(고사리) 팁 편향 지수 — 낮을수록 폭이 넓고 잎이 무성
var BIAS_NEEDLE = 4.6; // 우(침) 팁 편향 지수 — 높을수록 가늘고 긴 바늘
var PARTICLES = 22; // 결정 하나당 동시 입자 수
var MAX_AGE = 0.55; // 방금 붙은 칸이 빛나는 시간(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d', { alpha: false });
var lOut = document.getElementById('cdL');
var rOut = document.getElementById('cdR');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x319A03);
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_INK = tokRGB('--flake-ink');
var C_DEEP = tokRGB('--flake-deep');
var C_HAZE = tokRGB('--flake-haze');
var C_MOTE = tokRGB('--flake-mote');
var C_ICE = tokRGB('--flake-ice');
var C_GLOW = tokRGB('--flake-glow');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_SAND = tokRGB('--flake-sand'); // 우(침) 결정 바깥 색 — 모래빛 파스텔
var C_MIST = tokRGB('--flake-mist');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, t0 = -1;
var bg, data, img2, data2;
// ── 결정 하나의 상태 — 좌우 두 개를 같은 구조로 ──
function makeCrystal(n, ox, bias, seed) {
return {
N: n, ox: ox, bias: bias,
grid: new Uint8Array(n * n),
age: new Float32Array(n * n),
walk: [],
rand: makeRand(seed),
stuck: 0, r: 1,
cx: n >> 1, cy: n >> 1
};
}
var L = null, R = null;
var snow = [];
var cellPx = 4;
function gridIdx(c, i, j) { return i * c.N + j; }
function inGrid(c, i, j) { return i >= 0 && j >= 0 && i < c.N && j < c.N; }
var DJ_EVEN = [0, -1, -1, 0, 0, 0];
var DJ_ODD = [0, 0, 0, 0, 1, 1];
var DI = [-1, -1, 0, 1, 0, -1];
function nbr(c, i, j, k) { return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])]; }
function setCell(c, i, j, t) { c.grid[gridIdx(c, i, j)] = 1; c.age[gridIdx(c, i, j)] = t; }
function plantSeed(c) {
var cx = c.cx, cy = c.cy;
setCell(c, cx, cy, 0); setCell(c, cx - 1, cy, 0); setCell(c, cx + 1, cy, 0);
setCell(c, cx, cy - 1, 0); setCell(c, cx, cy + 1, 0);
setCell(c, cx - 1, cy - 1, 0); setCell(c, cx + 1, cy + 1, 0);
c.stuck = 7;
}
function launch(c) {
var rnd = c.rand;
var a = rnd() * Math.PI * 2;
var r = c.r * 1.22 + 3;
var i = c.cx + Math.round(Math.cos(a) * r);
var j = c.cy + Math.round(Math.sin(a) * r);
if (!inGrid(c, i, j) || c.grid[gridIdx(c, i, j)]) return;
c.walk.push([i, j, Math.floor(rnd() * 6), rnd() * Math.PI * 2]);
}
function touching(c, i, j) {
for (var k = 0; k < 6; k++) {
var nb = nbr(c, i, j, k);
if (inGrid(c, nb[0], nb[1]) && c.grid[gridIdx(c, nb[0], nb[1])]) return true;
}
return false;
}
// 팁 가중 흡착 — bias 가 결정의 습관(폭)을 정한다
function stickProb(c, i, j) {
var dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, c.r));
return Math.pow(k, 1) * 0.15 + Math.pow(k, c.bias) * 0.85 + 0.05;
}
function step(c, t) {
var rnd = c.rand;
for (var s = 0; s < 20; s++) {
for (var w = c.walk.length - 1; w >= 0; w--) {
var p = c.walk[w];
if (rnd() < 0.25) p[2] = Math.floor(rnd() * 6);
var nb = nbr(c, p[0], p[1], p[2]);
var i = nb[0], j = nb[1];
if (!inGrid(c, i, j)) { c.walk.splice(w, 1); continue; }
if (rnd() < 0.2) p[3] += (rnd() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(c, i, j)) {
if (rnd() < stickProb(c, i, j)) {
setCell(c, i, j, t);
c.stuck++;
var dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
if (dc + 2 > c.r) c.r = Math.min(c.N * 0.46, dc + 2);
}
} else if (Math.hypot(i - c.cx, j - c.cy) > c.r * 1.7 + 8) {
c.walk.splice(w, 1);
}
}
while (c.walk.length < PARTICLES) launch(c);
}
}
function buildSnow() {
for (var i = 0; i < 60; i++) {
snow.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.2,
v: 0.06 + rand() * 0.13,
sway: 0.013 + rand() * 0.032,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9
});
}
}
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function lerp(a, b, k) { return a + (b - a) * k; }
function drawBackdrop(t) {
var sc = 0.055;
for (var y = 0; y < H; y++) {
var u = y / H;
var r0 = C_INK[0] + (C_DEEP[0] - C_INK[0]) * u;
var g0 = C_INK[1] + (C_DEEP[1] - C_INK[1]) * u;
var b0 = C_INK[2] + (C_DEEP[2] - C_INK[2]) * u;
for (var x = 0; x < W; x++) {
var sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 16;
var j3 = (y * W + x) * 4;
data[j3] = r0 + sh;
data[j3 + 1] = g0 + sh;
data[j3 + 2] = b0 + sh * 1.3;
}
}
// 각 결정 주위 헤이즈 — 좌우 각각
var halves = [[L, W * 0.27], [R, W * 0.73]];
for (var h = 0; h < 2; h++) {
var c = halves[h][0], pcx = halves[h][1], pcy = H * 0.5;
for (var y2 = 0; y2 < H; y2 += 2) {
var dyv = y2 - pcy;
for (var x2 = Math.max(0, pcx - W * 0.28); x2 < Math.min(W, pcx + W * 0.28); x2 += 2) {
var dxv = x2 - pcx;
var d2 = dxv * dxv + dyv * dyv;
var haze = Math.exp(-d2 / (W * W * 0.008)) * 24;
for (var yy = y2; yy < Math.min(H, y2 + 2); yy++) {
for (var xx = x2; xx < Math.min(W, x2 + 2); xx++) {
var j4 = (yy * W + xx) * 4;
data[j4] += haze * (C_HAZE[0] / 255);
data[j4 + 1] += haze * (C_HAZE[1] / 255);
data[j4 + 2] += haze * (C_HAZE[2] / 255);
}
}
}
}
}
}
function putc(px, py, c, alpha) {
if (px < 0 || py < 0 || px >= W || py >= H) return;
var j3 = (py * W + px) * 4;
data2[j3] += (c[0] - data2[j3]) * alpha;
data2[j3 + 1] += (c[1] - data2[j3 + 1]) * alpha;
data2[j3 + 2] += (c[2] - data2[j3 + 2]) * alpha;
}
// left(고사리)는 화이트→라일락, 우(침)는 화이트→모래빛 — 색으로도 습관을 구분
function cellColor(c, g, ag, outer) {
var k = Math.min(1, g / (c.N * 0.38));
var base = [
lerp(C_ICE[0], outer[0], k),
lerp(C_ICE[1], outer[1], k),
lerp(C_ICE[2], outer[2], k)
];
if (ag < MAX_AGE) {
var b = Math.pow(1 - ag / MAX_AGE, 1.6);
base[0] += (C_GLOW[0] - base[0]) * (0.85 * b);
base[1] += (C_GLOW[1] - base[1]) * (0.85 * b);
base[2] += (C_GLOW[2] - base[2]) * (0.85 * b);
}
return base;
}
function drawCrystal(c, t, outer) {
var ox = c.ox, oy = (H - c.N * cellPx) / 2;
var rr = Math.max(1, Math.round(cellPx * 0.58));
var rr2 = rr * rr;
for (var i = 0; i < c.N; i++) {
for (var j = 0; j < c.N; j++) {
var gi = gridIdx(c, i, j);
if (!c.grid[gi]) continue;
var col = cellColor(c, Math.hypot(i - c.cx, (j - c.cy) * 0.92), t - c.age[gi], outer);
var px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + j * cellPx);
for (var dy = -rr; dy <= rr; dy++) {
for (var dx = -rr; dx <= rr; dx++) {
var dd = dx * dx + dy * dy;
if (dd > rr2) continue;
var px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
var j3 = (py2 * W + px2) * 4;
var a3 = 1 - dd / (rr2 + 1);
data2[j3] += (col[0] - data2[j3]) * a3;
data2[j3 + 1] += (col[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (col[2] - data2[j3 + 2]) * a3;
}
}
}
}
for (var wi = 0; wi < c.walk.length; wi++) {
var p = c.walk[wi];
var px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
var py = Math.round(oy + p[1] * cellPx);
var tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, C_MINT, 0.95);
putc(px + 1, py, C_MINT, 0.5 * tw);
putc(px - 1, py, C_MINT, 0.5 * tw);
putc(px, py + 1, C_MINT, 0.5 * tw);
putc(px, py - 1, C_MINT, 0.9 * tw);
}
}
function draw(t, dt) {
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
var f = 1 - Math.exp(-dt * 4.2);
for (var k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
drawCrystal(L, t, C_LILAC);
drawCrystal(R, t, C_SAND);
for (var si = 0; si < snow.length; si++) {
var s = snow[si];
var sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
var sy = ((s.y + s.v * t) % 1.15 - 0.07) * H;
var br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
var a4 = 0.55 + 0.4 * br;
var sr = Math.ceil(s.r);
for (var dy2 = -sr; dy2 <= sr; dy2++) {
for (var dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, C_MOTE, a4);
}
}
}
// 가운데 경계 — 얇은 안개 라인(대비 구분)
var mx = Math.round(W / 2);
for (var y3 = 0; y3 < H; y3++) {
putc(mx, y3, C_MIST, 0.10 + 0.06 * Math.sin(t * 1.4 + y3 * 0.05));
}
ctx.putImageData(img2, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
ctx = cv.getContext('2d', { alpha: false });
cellPx = Math.max(2, Math.min(4, Math.floor(Math.min(W / 2, H) / 46)));
var N = Math.floor(Math.min(W / 2, H) / cellPx);
if ((N & 1) === 0) N -= 1;
L = makeCrystal(N, (W / 2 - N * cellPx) / 2, BIAS_FERN, 0x319B01);
R = makeCrystal(N, W / 2 + (W / 2 - N * cellPx) / 2, BIAS_NEEDLE, 0x319B02);
plantSeed(L); plantSeed(R);
snow = [];
bg = ctx.createImageData(W, H); data = bg.data;
img2 = ctx.createImageData(W, H); data2 = img2.data;
for (var k = 3; k < data.length; k += 4) { data[k] = 255; data2[k] = 255; }
buildSnow();
}
function stillFrame() {
for (var k = 0; k < 240; k++) { step(L, 0.01 * k); step(R, 0.01 * k); }
draw(2.4, 0.016);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(L, t); step(R, t);
draw(t, dt);
if (lOut) lOut.textContent = String(L.stuck);
if (rOut) rOut.textContent = String(R.stuck);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// NeedleFernStar — vanilla 판(run/319/demo/03-snowflake-needle-fern-star/index.html) 의 React 포트.
// 침·고사리 결정 — 같은 DLA 규칙에 팁 편향 지수(가지 폭 확률) 하나만 달리한 두 결정을
// 좌우로 나란히 동시에 키운다. 좌(bias 1.6)는 넓은 고사리 잎, 우(bias 4.6)는 가늘고 긴 침.
// 실제 눈결정 형성 지도(Nakaya 다이어그램)의 축 하나를 파라미터로 압축한 대비 시연.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NeedleFernStar.module.scss";
export type NeedleFernStarProps = {
duration?: string;
easing?: string;
color?: string;
biasFern?: number; // 좌(고사리) 팁 편향 지수 — 낮을수록 폭이 넓고 잎이 무성
biasNeedle?: number; // 우(침) 팁 편향 지수 — 높을수록 가늘고 긴 바늘
maxAge?: number; // 방금 붙은 칸이 빛나는 시간(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Particle = [number, number, number, number];
type Snow = { x: number; y: number; r: number; v: number; sway: number; ph: number; tw: number };
type Colors = { ink: RGB; deep: RGB; haze: RGB; mote: RGB; ice: RGB; glow: RGB; lilac: RGB; mint: RGB; sand: RGB; mist: RGB };
type Crystal = {
N: number; ox: number; bias: number;
grid: Uint8Array; age: Float32Array;
walk: Particle[];
rand: () => number;
stuck: number; r: number;
cx: number; cy: number;
};
const FIELD_LABEL = "침·고사리 결정 대비 실험판";
const DI = [-1, -1, 0, 1, 0, -1];
const DJ_EVEN = [0, -1, -1, 0, 0, 0];
const DJ_ODD = [0, 0, 0, 0, 1, 1];
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function NeedleFernStar({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c9b6ff",
biasFern = 1.6,
biasNeedle = 4.6,
maxAge = 0.55,
}: NeedleFernStarProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const biasFernRef = useRef(biasFern) as Box<number>;
const biasNeedleRef = useRef(biasNeedle) as Box<number>;
const maxAgeRef = useRef(maxAge) as Box<number>;
const randRef = useRef(makePRNG(0x319a03)) as Box<() => number>;
const lRef = useRef<Crystal | null>(null) as Box<Crystal | null>;
const rRef2 = useRef<Crystal | null>(null) as Box<Crystal | null>;
const cellPxRef = useRef(4) as Box<number>;
const snowRef = useRef<Snow[]>([]) as Box<Snow[]>;
const bgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const img2Ref = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const rafRef = useRef(0) as Box<number>;
const [nl, setNl] = useState("0");
const [nr, setNr] = useState("0");
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 makeCrystal(n: number, ox: number, bias: number, seed: number): Crystal {
return {
N: n, ox, bias,
grid: new Uint8Array(n * n),
age: new Float32Array(n * n),
walk: [],
rand: makePRNG(seed),
stuck: 0, r: 1,
cx: n >> 1, cy: n >> 1,
};
}
function gridIdx(c: Crystal, i: number, j: number): number { return i * c.N + j; }
function inGrid(c: Crystal, i: number, j: number): boolean {
return i >= 0 && j >= 0 && i < c.N && j < c.N;
}
function nbr(c: Crystal, i: number, j: number, k: number): [number, number] {
return [i + DI[k], j + ((j & 1) === 0 ? DJ_EVEN[k] : DJ_ODD[k])];
}
function setCell(c: Crystal, i: number, j: number, t: number): void {
c.grid[gridIdx(c, i, j)] = 1;
c.age[gridIdx(c, i, j)] = t;
}
function plantSeed(c: Crystal): void {
const cx = c.cx, cy = c.cy;
setCell(c, cx, cy, 0); setCell(c, cx - 1, cy, 0); setCell(c, cx + 1, cy, 0);
setCell(c, cx, cy - 1, 0); setCell(c, cx, cy + 1, 0);
setCell(c, cx - 1, cy - 1, 0); setCell(c, cx + 1, cy + 1, 0);
c.stuck = 7;
}
function launch(c: Crystal): void {
const rnd = c.rand;
const a = rnd() * Math.PI * 2;
const r = c.r * 1.22 + 3;
const i = c.cx + Math.round(Math.cos(a) * r);
const j = c.cy + Math.round(Math.sin(a) * r);
if (!inGrid(c, i, j) || c.grid[gridIdx(c, i, j)]) return;
c.walk.push([i, j, Math.floor(rnd() * 6), rnd() * Math.PI * 2]);
}
function touching(c: Crystal, i: number, j: number): boolean {
for (let k = 0; k < 6; k++) {
const nb = nbr(c, i, j, k);
if (inGrid(c, nb[0], nb[1]) && c.grid[gridIdx(c, nb[0], nb[1])]) return true;
}
return false;
}
function stickProb(c: Crystal, i: number, j: number): number {
const dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
const k = Math.min(1, dc / Math.max(1, c.r));
return Math.pow(k, 1) * 0.15 + Math.pow(k, c.bias) * 0.85 + 0.05;
}
function step(c: Crystal, t: number): void {
const rnd = c.rand;
for (let s = 0; s < 20; s++) {
for (let w = c.walk.length - 1; w >= 0; w--) {
const p = c.walk[w];
if (rnd() < 0.25) p[2] = Math.floor(rnd() * 6);
const nb = nbr(c, p[0], p[1], p[2]);
const i = nb[0], j = nb[1];
if (!inGrid(c, i, j)) { c.walk.splice(w, 1); continue; }
if (rnd() < 0.2) p[3] += (rnd() - 0.5) * 0.7;
p[0] = i; p[1] = j;
if (touching(c, i, j)) {
if (rnd() < stickProb(c, i, j)) {
setCell(c, i, j, t);
c.stuck++;
const dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
if (dc + 2 > c.r) c.r = Math.min(c.N * 0.46, dc + 2);
}
} else if (Math.hypot(i - c.cx, j - c.cy) > c.r * 1.7 + 8) {
c.walk.splice(w, 1);
}
}
while (c.walk.length < 22) launch(c);
}
}
function buildSnow(): void {
const rand = randRef.current;
const arr: Snow[] = [];
for (let i = 0; i < 60; i++) {
arr.push({
x: rand(), y: rand(),
r: 1.1 + rand() * 2.2,
v: 0.06 + rand() * 0.13,
sway: 0.013 + rand() * 0.032,
ph: rand() * Math.PI * 2,
tw: 0.6 + rand() * 0.9,
});
}
snowRef.current = arr;
}
function buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function drawBackdrop(t: number): void {
const col = colRef.current;
const bg = bgRef.current;
if (!col || !bg) return;
const { w: W, h: H } = sizeRef.current;
const data = bg.data;
const sc = 0.055;
for (let y = 0; y < H; y++) {
const u = y / H;
const r0 = col.ink[0] + (col.deep[0] - col.ink[0]) * u;
const g0 = col.ink[1] + (col.deep[1] - col.ink[1]) * u;
const b0 = col.ink[2] + (col.deep[2] - col.ink[2]) * u;
for (let x = 0; x < W; x++) {
const sh = (vnoise(x * sc, y * sc + t * 0.35) - 0.5) * 16;
const j3 = (y * W + x) * 4;
data[j3] = r0 + sh;
data[j3 + 1] = g0 + sh;
data[j3 + 2] = b0 + sh * 1.3;
}
}
const halves: [Crystal | null, number][] = [[lRef.current, W * 0.27], [rRef2.current, W * 0.73]];
for (let h = 0; h < 2; h++) {
const c = halves[h][0];
if (!c) continue;
const pcx = halves[h][1], pcy = H * 0.5;
for (let y2 = 0; y2 < H; y2 += 2) {
const dyv = y2 - pcy;
for (let x2 = Math.max(0, pcx - W * 0.28); x2 < Math.min(W, pcx + W * 0.28); x2 += 2) {
const dxv = x2 - pcx;
const d2 = dxv * dxv + dyv * dyv;
const haze = Math.exp(-d2 / (W * W * 0.008)) * 24;
for (let yy = y2; yy < Math.min(H, y2 + 2); yy++) {
for (let xx = x2; xx < Math.min(W, x2 + 2); xx++) {
const j4 = (yy * W + xx) * 4;
data[j4] += haze * (col.haze[0] / 255);
data[j4 + 1] += haze * (col.haze[1] / 255);
data[j4 + 2] += haze * (col.haze[2] / 255);
}
}
}
}
}
}
function putc(px: number, py: number, c: RGB, alpha: number): void {
const { w: W, h: H } = sizeRef.current;
const img2 = img2Ref.current;
if (!img2 || px < 0 || py < 0 || px >= W || py >= H) return;
const data = img2.data;
const j3 = (py * W + px) * 4;
data[j3] += (c[0] - data[j3]) * alpha;
data[j3 + 1] += (c[1] - data[j3 + 1]) * alpha;
data[j3 + 2] += (c[2] - data[j3 + 2]) * alpha;
}
function cellColor(c: Crystal, g: number, ag: number, outer: RGB): RGB {
const col = colRef.current!;
const k = Math.min(1, g / (c.N * 0.38));
const base: RGB = [
lerp(col.ice[0], outer[0], k),
lerp(col.ice[1], outer[1], k),
lerp(col.ice[2], outer[2], k),
];
if (ag < maxAgeRef.current) {
const b = Math.pow(1 - ag / maxAgeRef.current, 1.6);
base[0] += (col.glow[0] - base[0]) * (0.85 * b);
base[1] += (col.glow[1] - base[1]) * (0.85 * b);
base[2] += (col.glow[2] - base[2]) * (0.85 * b);
}
return base;
}
function drawCrystal(c: Crystal, t: number, outer: RGB): void {
const { w: W, h: H } = sizeRef.current;
const cellPx = cellPxRef.current;
const ox = c.ox, oy = (H - c.N * cellPx) / 2;
const rr = Math.max(1, Math.round(cellPx * 0.58));
const rr2 = rr * rr;
for (let i = 0; i < c.N; i++) {
for (let j = 0; j < c.N; j++) {
const gi = gridIdx(c, i, j);
if (!c.grid[gi]) continue;
const colr = cellColor(c, Math.hypot(i - c.cx, (j - c.cy) * 0.92), t - c.age[gi], outer);
const px = Math.round(ox + i * cellPx + ((j & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + j * cellPx);
for (let dy = -rr; dy <= rr; dy++) {
for (let dx = -rr; dx <= rr; dx++) {
const dd = dx * dx + dy * dy;
if (dd > rr2) continue;
const px2 = px + dx, py2 = py + dy;
if (px2 < 0 || py2 < 0 || px2 >= W || py2 >= H) continue;
const j3 = (py2 * W + px2) * 4;
const a3 = 1 - dd / (rr2 + 1);
const data2 = img2Ref.current!.data;
data2[j3] += (colr[0] - data2[j3]) * a3;
data2[j3 + 1] += (colr[1] - data2[j3 + 1]) * a3;
data2[j3 + 2] += (colr[2] - data2[j3 + 2]) * a3;
}
}
}
}
for (let wi = 0; wi < c.walk.length; wi++) {
const p = c.walk[wi];
const px = Math.round(ox + p[0] * cellPx + ((p[1] & 1) ? cellPx * 0.5 : 0));
const py = Math.round(oy + p[1] * cellPx);
const tw = 0.55 + 0.45 * Math.sin(t * 7 + p[3] * 3);
putc(px, py, colRef.current!.mint, 0.95);
putc(px + 1, py, colRef.current!.mint, 0.5 * tw);
putc(px - 1, py, colRef.current!.mint, 0.5 * tw);
putc(px, py + 1, colRef.current!.mint, 0.5 * tw);
putc(px, py - 1, colRef.current!.mint, 0.9 * tw);
}
}
function draw(t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bg = bgRef.current;
const img2 = img2Ref.current;
if (!ctx || !col || !bg || !img2) return;
const { w: W, h: H } = sizeRef.current;
drawBackdrop(t);
ctx.putImageData(bg, 0, 0);
const data = bg.data, data2 = img2.data;
const f = 1 - Math.exp(-dt * 4.2);
for (let k = 0; k < data2.length; k += 4) {
data2[k] += (data[k] - data2[k]) * f;
data2[k + 1] += (data[k + 1] - data2[k + 1]) * f;
data2[k + 2] += (data[k + 2] - data2[k + 2]) * f;
}
drawCrystal(lRef.current!, t, col.lilac);
drawCrystal(rRef2.current!, t, col.sand);
const snow = snowRef.current;
for (let si = 0; si < snow.length; si++) {
const s = snow[si];
const sx = (s.x + Math.sin(t * s.sway * 6 + s.ph) * 0.035) * W;
const sy = (((s.y + s.v * t) % 1.15) - 0.07) * H;
const br = 0.5 + 0.5 * Math.sin(t * s.tw * 2.4 + s.ph);
const a4 = 0.55 + 0.4 * br;
const sr = Math.ceil(s.r);
for (let dy2 = -sr; dy2 <= sr; dy2++) {
for (let dx2 = -sr; dx2 <= sr; dx2++) {
if (dx2 * dx2 + dy2 * dy2 > s.r * s.r) continue;
putc(Math.round(sx) + dx2, Math.round(sy) + dy2, col.mote, a4);
}
}
}
const mx = Math.round(W / 2);
for (let y3 = 0; y3 < H; y3++) {
putc(mx, y3, col.mist, 0.1 + 0.06 * Math.sin(t * 1.4 + y3 * 0.05));
}
ctx.putImageData(img2, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const cellPx = Math.max(2, Math.min(4, Math.floor(Math.min(W / 2, H) / 46)));
cellPxRef.current = cellPx;
let N = Math.floor(Math.min(W / 2, H) / cellPx);
if ((N & 1) === 0) N -= 1;
lRef.current = makeCrystal(N, (W / 2 - N * cellPx) / 2, biasFernRef.current, 0x319b01);
rRef2.current = makeCrystal(N, W / 2 + (W / 2 - N * cellPx) / 2, biasNeedleRef.current, 0x319b02);
plantSeed(lRef.current);
plantSeed(rRef2.current);
const bg = ctx.createImageData(W, H);
const img2 = ctx.createImageData(W, H);
for (let k = 3; k < bg.data.length; k += 4) { bg.data[k] = 255; img2.data[k] = 255; }
bgRef.current = bg;
img2Ref.current = img2;
buildSnow();
}
function stillFrame(): void {
for (let k = 0; k < 240; k++) {
step(lRef.current!, 0.01 * k);
step(rRef2.current!, 0.01 * k);
}
draw(2.4, 0.016);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
step(lRef.current!, t);
step(rRef2.current!, t);
draw(t, dt);
setNl(String(lRef.current!.stuck));
setNr(String(rRef2.current!.stuck));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
biasFernRef.current = biasFern;
biasNeedleRef.current = biasNeedle;
maxAgeRef.current = maxAge;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
deep: tokRGB(root, "--flake-deep"),
haze: tokRGB(root, "--flake-haze"),
mote: tokRGB(root, "--flake-mote"),
ice: tokRGB(root, "--flake-ice"),
glow: tokRGB(root, "--flake-glow"),
lilac: tokRGB(root, "--flake-lilac"),
mint: tokRGB(root, "--flake-mint"),
sand: tokRGB(root, "--flake-sand"),
mist: tokRGB(root, "--flake-mist"),
};
randRef.current = makePRNG(0x319a03);
buildPerm();
fit();
setNl("0"); setNr("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
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
}, [biasFern, biasNeedle, maxAge]);
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>같은 응집 규칙에 파라미터 하나(가지 폭 확률)만 달리한 두 결정을 나란히 키우면, 넓은 고사리형 잎과 좁은 침형이 한 화면에서 갈라집니다 — 진짜 눈결정이 온도·습도 조건에 따라 모양이 갈리는 지도입니다</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 className={styles.val}>{nl}</b> · 우 <b className={styles.valAlt}>{nr}</b>개 응집</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · Needles and ferns — shift the humidity band and ferns turn to needles — Measured area 41.614% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
bias is the only value that tells a fern from a needle
// tip-weighted sticking — bias sets the crystal's habit (width)
function stickProb(c, i, j) {
var dc = Math.hypot(i - c.cx, (j - c.cy) * 0.92);
var k = Math.min(1, dc / Math.max(1, c.r));
return Math.pow(k, 1) * 0.15 + Math.pow(k, c.bias) * 0.85 + 0.05;
}
04Six-fold mirror — fold one sector, stamp the rest
Fold each particle's angle onto a 60-degree period so the entire random walk is confined to one sector, then stamp the stuck cells out through 12 rotated-and-mirrored copies — a sixth of the compute, and the flake's real D6 symmetry lands with not one pixel off.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="22" stroke-linecap="round"><path d="M128 28v200"/><path d="M45 76l166 104"/><path d="M211 76L45 180"/></g></svg>
<span>Only one 60-degree fan-shaped sector is simulated, and the rest is filled in by rotating and mirroring it — the computation drops to 1/6 while a perfectly symmetric hexagonal crystal still grows</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Six-Fold Symmetry Crystal Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">sectors <b class="cd__val" id="cdSector"></b> · stamps <b class="cd__val" id="cdCopies"></b> · aggregated <b class="cd__val" id="cdRate"></b>/sec</span>
<span class="cd__meta cd__meta--dim">Canvas · rotate · ImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 육중 대칭 거울(04). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (섹터 DLA 응집 + 회전·거울 도장 + 배경 오로라 맥동 + 부유 수증기)가 만든다.
// 무대는 한겨울 밤잉크. 파스텔 팔레트를 --flake-* 로 편다(편 319 공통 규격).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flake-ink: #20242e; // 한겨울 밤 무대 — 316 bolt 보다 푸른 잉크
$flake-dusk: #4a516e; // 슬레이트
$flake-core: #f4f0fa; // 결정 코어 — 서리 화이트
$flake-halo: #b8cce8; // 헤일로 아이스블루
$flake-mint: #c8e0d8; // 민트 보조
$flake-sky: #a4bcd8; // 스카이 보조
$flake-mist: #dce8f4; // 서리 안개
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-dusk: #{$flake-dusk};
--flake-core: #{$flake-core};
--flake-halo: #{$flake-halo};
--flake-mint: #{$flake-mint};
--flake-sky: #{$flake-sky};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist; // 잉크 무대 위 — 서리 톤 글자
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-halo; font-weight: 700; } // 잉크 위 아이스블루 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 자라난 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #20242e;
--flake-dusk: #4a516e;
--flake-core: #f4f0fa;
--flake-halo: #b8cce8;
--flake-mint: #c8e0d8;
--flake-sky: #a4bcd8;
--flake-mist: #dce8f4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #dce8f4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8cce8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #20242e;
box-shadow: inset 0 0 0 1px rgba(220, 232, 244, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b8cce8;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(220, 232, 244, 0.72);
}
.cd__val {
color: #b8cce8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 육중 대칭 거울(04). 60도 sectors 하나에서만 DLA 랜덤워크를 돌린다. 입자가 섹터를 벗어나면
// 각도를 60도 주기로 접어(회전 접기) 안으로 되돌려 보내고, 붙은 셀은 화면에 그릴 때
// 여섯 방향 회전 + 거울(±각) 도장 12장으로 펼친다. 시뮬레이션 비용은 1/6 인데 대칭은
// 픽셀 하나 안 어긋난다 — 눈결정이 원래 가진 D6 대칭(회전 6 × 거울) 그대로.
// 화면은 매 프레임 배경 그라디언트 + 오로라 맥동 + 부유 입자 + 결정 도장을 ImageData 한 장으로 다시 쓴다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var SECTOR_DEG = 60; // sectors 각(도) — 변형 "대칭 접기" 축(60 → 30, 도장 수는 360/각×2)
var SPS = 320; // 초당 응집 입자 수 — 변형 "여섯 배 속도" 축(90 → 320)
var N = 44; // sectors grid cells 반경(셀 수) — 셀이 커질수록 결정이 화면을 넓게 채운다
// ────────────────────────────────────────────────────
var SECTOR = SECTOR_DEG * Math.PI / 180;
var COPIES = Math.round(360 / SECTOR_DEG);
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdSector').textContent = SECTOR_DEG + '°';
document.getElementById('cdCopies').textContent = String(COPIES * 2);
document.getElementById('cdRate').textContent = String(SPS);
// 시드 고정 PRNG — mulberry32
var SEED = 0x3190404;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 커스텀 프로퍼티에서 받는다 — 파스텔 토큰이 scss 한 곳에
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_INK = tokRGB('--flake-ink');
var C_DUSK = tokRGB('--flake-dusk');
var C_CORE = tokRGB('--flake-core');
var C_HALO = tokRGB('--flake-halo');
var C_MINT = tokRGB('--flake-mint');
var C_SKY = tokRGB('--flake-sky');
var C_MIST = tokRGB('--flake-mist');
// LUT — 나이 t(0=코어 쪽 오래된 셀, 1=막 붙은 팁)를 잉크→어스름→라일락→코어→흰끼 ramp 로
var lutR = new Uint8ClampedArray(256), lutG = new Uint8ClampedArray(256), lutB = new Uint8ClampedArray(256);
function buildLUT() {
var m = [0, 0.30, 0.60, 0.86, 1];
var cs = [
[C_INK[0], C_INK[1], C_INK[2]],
[C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.7, C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.7, C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.7],
[C_HALO[0], C_HALO[1], C_HALO[2]],
[C_CORE[0], C_CORE[1], C_CORE[2]],
[255, 253, 255]
];
for (var i = 0; i < 256; i++) {
var t = i / 255, k = 0;
while (k < 3 && t > m[k + 1]) k++;
var f = (t - m[k]) / (m[k + 1] - m[k] || 1);
lutR[i] = cs[k][0] + (cs[k + 1][0] - cs[k][0]) * f;
lutG[i] = cs[k][1] + (cs[k + 1][1] - cs[k][1]) * f;
lutB[i] = cs[k][2] + (cs[k + 1][2] - cs[k][2]) * f;
}
}
// ── sectors grid cells — 꼭짓점(결정 중심)을 원점, +y 방향 ±30도 부채꼴 ──
var WS = 2 * N + 8, HS = N + 6, AX = N + 4, AY = 2;
var occ = new Uint8Array(WS * HS);
var cells = []; // 붙은 셀 [각도, 반경, 순서] 납작 배열 — 도장할 때 그대로 순회
var order = 0, maxR = 1.5;
var phase = 0; // 0 자라기 · 1 샤인(다 자란 뒤 반짝임) · 리셋
var shineT = 0;
var walkers = []; // {fx, fy} — 꼭짓점 기준 상대 좌표
var gRot = 0; // 결정 전체의 느린 자전(도 단위로 치면 분당 몇 바퀴)
var WALKERS = 42;
// 각도를 sectors(-SECTOR/2 .. SECTOR/2)로 60도 cycle 접는다 — 회전 접기(C6). 걸음이 부채꼴을
// 나가도 접혀 안으로 되돌아오므로 시뮬레이션 공간이 sectors 하나로 끝난다.
function foldTh(th) {
var P = SECTOR, m = (th + P * 0.5) % P;
if (m < 0) m += P;
return m - P * 0.5;
}
// 상대 좌표 → 접힌 (각도, 반경)
function fold(fx, fy) {
var r = Math.sqrt(fx * fx + fy * fy);
var th = r < 1e-9 ? 0 : foldTh(Math.atan2(fx, fy));
return [th, r];
}
function cellOcc(fx, fy) {
var fo = fold(fx, fy);
if (fo[1] > N) return 0;
var gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
var gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) return 0;
return occ[gy * WS + gx];
}
function respawn(w) {
var r0 = Math.min(maxR + 12, N - 1);
var a = (rand() * 2 - 1) * (SECTOR * 0.46);
w.fx = Math.sin(a) * r0;
w.fy = Math.cos(a) * r0;
}
var DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w) {
var d = (rand() * 4) | 0;
w.fx += DX[d]; w.fy += DY[d];
var fo = fold(w.fx, w.fy);
if (fo[1] > N) { respawn(w); return false; }
var gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
var gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) { respawn(w); return false; }
var idx = gy * WS + gx;
if (occ[idx]) return false;
// 이웃 접촉 — 이웃도 접어서 본다(부채꼴 경계 너머 접촉을 놓치지 않게)
var nc = cellOcc(w.fx + 1, w.fy) + cellOcc(w.fx - 1, w.fy) + cellOcc(w.fx, w.fy + 1) + cellOcc(w.fx, w.fy - 1);
if (!nc) return false;
// 팁 쪽에 잘 붙게 — 이웃이 많은 자리(움푹한 곳)는 붙을 확률을 낮춘다(수지상 가지)
var p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) { respawn(w); return false; }
occ[idx] = 1;
cells.push(fo[0], fo[1], order);
order++;
if (fo[1] > maxR) maxR = fo[1];
respawn(w);
return true;
}
function resetSim() {
occ.fill(0);
cells = [];
order = 0;
maxR = 3;
phase = 0;
shineT = 0;
// 씨앗 — 꼭짓점에서 +y 로 세 셀(중심축)
for (var s = 1; s <= 3; s++) { occ[(AY + s) * WS + AX] = 1; cells.push(0, s, order); order++; }
walkers = [];
for (var i = 0; i < WALKERS; i++) { var w = { fx: 0, fy: 0 }; respawn(w); walkers.push(w); }
}
// ── 화면 ──
var W = 0, H = 0, img = null;
var motes = []; // 부유 수증기 — 결정 중심으로 흘러들며 먹이가 된다
var MOTES = 90;
function spawnMote(m, edge) {
var side = (rand() * 4) | 0;
if (side === 0) { m.x = rand() * W; m.y = edge ? -4 : rand() * H; }
else if (side === 1) { m.x = edge ? W + 4 : rand() * W; m.y = rand() * H; }
else if (side === 2) { m.x = rand() * W; m.y = edge ? H + 4 : rand() * H; }
else { m.x = edge ? -4 : rand() * W; m.y = rand() * H; }
m.c = rand();
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
prngState = SEED >>> 0;
gRot = 0;
motes = [];
for (var m = 0; m < MOTES; m++) { var mo = { x: 0, y: 0, c: 0 }; spawnMote(mo, false); motes.push(mo); }
resetSim();
}
function sim(dt) {
if (phase === 1) {
shineT += dt;
if (shineT > 950) resetSim();
return;
}
var quota = SPS * dt / 1000; // 이번 프레임에 붙일 입자 수(시간 비례)
var stuck = 0, steps = 0, maxSteps = (dt / 16.7) * WALKERS * 9;
while (stuck < quota && steps < maxSteps) {
for (var i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota || steps >= maxSteps) break;
}
}
if (maxR > N - 2) { phase = 1; shineT = 0; }
}
// 알파 블렌드 도장 — img 위에 직접(배경은 이미 그려져 있다)
function stamp(px, py, R, G, B, a) {
var x = px | 0, y = py | 0;
if (x < 0 || x >= W || y < 0 || y >= H) return;
var j = (y * W + x) << 2, d = img.data;
d[j] = d[j] * (1 - a) + R * a;
d[j + 1] = d[j + 1] * (1 - a) + G * a;
d[j + 2] = d[j + 2] * (1 - a) + B * a;
}
function stampSoft(px, py, R, G, B, a) {
// 5×5 옅은 헤일로 → 3×3 반투명 → 중심 진하게
var x = px | 0, y = py | 0;
for (var yy = y - 2; yy <= y + 2; yy++)
for (var xx = x - 2; xx <= x + 2; xx++)
stamp(xx, yy, C_HALO[0], C_HALO[1], C_HALO[2], a * 0.10);
for (var y2 = y - 1; y2 <= y + 1; y2++)
for (var x2 = x - 1; x2 <= x + 1; x2++)
stamp(x2, y2, R, G, B, a * 0.42);
stamp(x, y, R, G, B, Math.min(1, a));
}
var fr = 0;
function compose() {
var d = img.data;
// ① 배경 — 세로 그라디언트(잉크→어스름) + 움직이는 오로라 얼룩 네 개(결정 주위 수증기 빛)
var topR = C_INK[0] + 6, topG = C_INK[1] + 6, topB = C_INK[2] + 9;
var botR = C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.34, botG = C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.34, botB = C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.34;
var a1x = W * (0.18 + 0.10 * Math.sin(fr * 0.011)), a1y = H * (0.24 + 0.12 * Math.cos(fr * 0.013));
var a2x = W * (0.84 + 0.09 * Math.cos(fr * 0.009)), a2y = H * (0.28 + 0.10 * Math.sin(fr * 0.015));
var a3x = W * (0.24 + 0.08 * Math.cos(fr * 0.012 + 2)), a3y = H * (0.82 + 0.09 * Math.sin(fr * 0.010 + 1));
var a4x = W * (0.78 + 0.10 * Math.sin(fr * 0.008 + 4)), a4y = H * (0.78 + 0.11 * Math.cos(fr * 0.014 + 3));
var p1 = 0.085 + 0.050 * Math.sin(fr * 0.021);
var p2 = 0.075 + 0.045 * Math.cos(fr * 0.017);
var p3 = 0.070 + 0.040 * Math.sin(fr * 0.019 + 2);
var p4 = 0.080 + 0.048 * Math.cos(fr * 0.016 + 1);
var sig = 1 / (H * H * 0.55);
for (var y = 0; y < H; y++) {
var ty = y / H;
var br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
var row = y * W;
for (var x = 0; x < W; x++) {
var dx1 = x - a1x, dy1 = y - a1y, dx2 = x - a2x, dy2 = y - a2y;
var dx3 = x - a3x, dy3 = y - a3y, dx4 = x - a4x, dy4 = y - a4y;
var g = p1 * Math.exp(-(dx1 * dx1 + dy1 * dy1) * sig)
+ p2 * Math.exp(-(dx2 * dx2 + dy2 * dy2) * sig)
+ p3 * Math.exp(-(dx3 * dx3 + dy3 * dy3) * sig)
+ p4 * Math.exp(-(dx4 * dx4 + dy4 * dy4) * sig);
var j = (row + x) << 2;
d[j] = br + (C_HALO[0] - br) * g;
d[j + 1] = bg + (C_HALO[1] - bg) * g;
d[j + 2] = bb + (C_HALO[2] - bb) * g;
}
}
// ② 결정 도장 — sectors 셀 하나를 회전 COPIES + 거울(±각) 2배 = COPIES*2 장으로 펼친다
var cx = W / 2, cy = H / 2;
var scale = (Math.min(W, H) / 2 - 8) / N;
var shineWave = phase === 1 ? shineT * 0.018 : 0;
for (var i = 0; i < cells.length; i += 3) {
var th = cells[i], r = cells[i + 1], q = order > 1 ? cells[i + 2] / order : 1;
var li = 0.40 + 0.60 * q; // 새 팁일수록 밝다
if (phase === 1) li += 0.30 * Math.sin(shineWave - r * 0.22); // 샤인 — 반경 파동
if (li < 0) li = 0; else if (li > 1) li = 1;
var ci = (li * 255) | 0;
var R = lutR[ci], G = lutG[ci], B = lutB[ci];
var rr = r * scale;
for (var k = 0; k < COPIES; k++) {
var base = gRot + k * SECTOR;
stampSoft(cx + rr * Math.sin(base + th), cy + rr * Math.cos(base + th), R, G, B, 0.85);
stampSoft(cx + rr * Math.sin(base - th), cy + rr * Math.cos(base - th), R, G, B, 0.85);
}
}
// ③ 부유 수증기 — 중심으로 살랑이며 흘러들어가는 먹이
for (var m = 0; m < motes.length; m++) {
var mo = motes[m];
var ddx = cx - mo.x, ddy = cy - mo.y, dd = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
var sp = 0.05 + mo.c * 0.05;
mo.x += (ddx / dd) * sp + (rand() - 0.5) * 1.5;
mo.y += (ddy / dd) * sp + (rand() - 0.5) * 1.5;
if (dd < 12) spawnMote(mo, true);
var mc = mo.c < 0.34 ? C_MINT : (mo.c < 0.67 ? C_SKY : C_MIST);
var ma = 0.34 + 0.22 * Math.sin(fr * 0.09 + m * 1.7);
stamp(mo.x, mo.y, mc[0], mc[1], mc[2], ma);
stamp(mo.x + 1, mo.y, mc[0], mc[1], mc[2], ma * 0.45);
stamp(mo.x, mo.y + 1, mc[0], mc[1], mc[2], ma * 0.45);
}
ctx.putImageData(img, 0, 0);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
gRot += dt * 0.00022; // 느린 자전 — 결정 전체가 12도/초 로 돈다
sim(dt);
compose();
fr++;
raf = requestAnimationFrame(frame);
}
// reduce 용 한 장 — 1.4초 치 성장을 몰아 시뮬레이션하고 정지 화면 한 장만 남긴다
function stillDraw() {
fit();
for (var i = 0; i < 42; i++) sim(33);
gRot = 0.3;
compose();
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
buildLUT();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SixFoldMirror — vanilla 판(run/319/demo/04-snowflake-six-fold-symmetry-mirror/index.html) 의 React 포트.
// 60도 섹터 하나에만 DLA 랜덤워크를 돌리고(각도를 60도 주기로 접어 부채꼴 밖을 안으로 되돌린다),
// 붙은 셀을 화면에 그릴 때 회전 COPIES + 거울(±각) 도장으로 펼친다 — 계산은 1/6, 대칭은 완벽.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SixFoldMirror.module.scss";
export type SixFoldMirrorProps = {
duration?: string;
easing?: string;
color?: string;
sectorDeg?: number; // 섹터 각(도) — 변형 "대칭 접기" 축(60 → 30)
sps?: number; // 초당 응집 입자 수 — 변형 "여섯 배 속도" 축(90 → 320)
n?: number; // 섹터 격자 반경(셀 수)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Walker = { fx: number; fy: number };
type Mote = { x: number; y: number; c: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; halo: RGB; mint: RGB; sky: RGB; mist: RGB };
const FIELD_LABEL = "육중 대칭 결정 실험판";
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function SixFoldMirror({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b8cce8",
sectorDeg = 60,
sps = 320,
n = 44,
}: SixFoldMirrorProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const lutRef = useRef<{ r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3190404)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [copiesShown] = useState(Math.round(360 / sectorDeg) * 2);
const [spsShown] = useState(sps);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// ── 섹터 상태(ref 로 보관 — rAF 루프에서만 갱신, 리렌더 유발 안 함) ──
const SECTOR = (sectorDeg * Math.PI) / 180;
const COPIES = Math.round(360 / sectorDeg);
const WS = 2 * n + 8, HS = n + 6, AX = n + 4, AY = 2;
const occRef = useRef<Uint8Array>(new Uint8Array(WS * HS)) as Box<Uint8Array>;
const cellsRef = useRef<number[]>([]) as Box<number[]>;
const orderRef = useRef(0) as Box<number>;
const maxRRef = useRef(1.5) as Box<number>;
const phaseRef = useRef(0) as Box<number>;
const shineTRef = useRef(0) as Box<number>;
const walkersRef = useRef<Walker[]>([]) as Box<Walker[]>;
const gRotRef = useRef(0) as Box<number>;
const frRef = useRef(0) as Box<number>;
const motesRef = useRef<Mote[]>([]) as Box<Mote[]>;
const WALKERS = 42, MOTES = 90;
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 buildLUT(col: Colors): void {
const r = new Uint8ClampedArray(256), g = new Uint8ClampedArray(256), b = new Uint8ClampedArray(256);
const m = [0, 0.30, 0.60, 0.86, 1];
const cs: RGB[] = [
col.ink,
[col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.7, col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.7, col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.7],
col.halo,
col.core,
[255, 253, 255],
];
for (let i = 0; i < 256; i++) {
const t = i / 255;
let k = 0;
while (k < 3 && t > m[k + 1]) k++;
const f = (t - m[k]) / (m[k + 1] - m[k] || 1);
r[i] = cs[k][0] + (cs[k + 1][0] - cs[k][0]) * f;
g[i] = cs[k][1] + (cs[k + 1][1] - cs[k][1]) * f;
b[i] = cs[k][2] + (cs[k + 1][2] - cs[k][2]) * f;
}
lutRef.current = { r, g, b };
}
function foldTh(th: number): number {
const P = SECTOR;
let m = (th + P * 0.5) % P;
if (m < 0) m += P;
return m - P * 0.5;
}
function fold(fx: number, fy: number): [number, number] {
const r = Math.sqrt(fx * fx + fy * fy);
const th = r < 1e-9 ? 0 : foldTh(Math.atan2(fx, fy));
return [th, r];
}
function cellOcc(fx: number, fy: number): number {
const occ = occRef.current;
const fo = fold(fx, fy);
if (fo[1] > n) return 0;
const gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
const gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) return 0;
return occ[gy * WS + gx];
}
function respawn(w: Walker): void {
const rand = randRef.current;
const r0 = Math.min(maxRRef.current + 12, n - 1);
const a = (rand() * 2 - 1) * (SECTOR * 0.46);
w.fx = Math.sin(a) * r0;
w.fy = Math.cos(a) * r0;
}
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w: Walker): boolean {
const rand = randRef.current;
const occ = occRef.current;
const d = (rand() * 4) | 0;
w.fx += DX[d]; w.fy += DY[d];
const fo = fold(w.fx, w.fy);
if (fo[1] > n) { respawn(w); return false; }
const gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
const gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) { respawn(w); return false; }
const idx = gy * WS + gx;
if (occ[idx]) return false;
const nc = cellOcc(w.fx + 1, w.fy) + cellOcc(w.fx - 1, w.fy) + cellOcc(w.fx, w.fy + 1) + cellOcc(w.fx, w.fy - 1);
if (!nc) return false;
const p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) { respawn(w); return false; }
occ[idx] = 1;
cellsRef.current.push(fo[0], fo[1], orderRef.current);
orderRef.current++;
if (fo[1] > maxRRef.current) maxRRef.current = fo[1];
respawn(w);
return true;
}
function resetSim(): void {
occRef.current.fill(0);
cellsRef.current = [];
orderRef.current = 0;
maxRRef.current = 3;
phaseRef.current = 0;
shineTRef.current = 0;
for (let s = 1; s <= 3; s++) {
occRef.current[(AY + s) * WS + AX] = 1;
cellsRef.current.push(0, s, orderRef.current);
orderRef.current++;
}
const ws: Walker[] = [];
for (let i = 0; i < WALKERS; i++) { const w: Walker = { fx: 0, fy: 0 }; respawn(w); ws.push(w); }
walkersRef.current = ws;
}
function spawnMote(m: Mote, edge: boolean): void {
const rand = randRef.current;
const { w: W, h: H } = sizeRef.current;
const side = (rand() * 4) | 0;
if (side === 0) { m.x = rand() * W; m.y = edge ? -4 : rand() * H; }
else if (side === 1) { m.x = edge ? W + 4 : rand() * W; m.y = rand() * H; }
else if (side === 2) { m.x = rand() * W; m.y = edge ? H + 4 : rand() * H; }
else { m.x = edge ? -4 : rand() * W; m.y = rand() * H; }
m.c = rand();
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current.w = W;
sizeRef.current.h = H;
cv.width = W; cv.height = H;
ctxRef.current = ctx;
const img = ctx.createImageData(W, H);
const d = img.data;
for (let i = 3; i < d.length; i += 4) d[i] = 255;
imgRef.current = img;
randRef.current = makePRNG(0x3190404);
gRotRef.current = 0;
frRef.current = 0;
const ms: Mote[] = [];
for (let m = 0; m < MOTES; m++) { const mo: Mote = { x: 0, y: 0, c: 0 }; spawnMote(mo, false); ms.push(mo); }
motesRef.current = ms;
resetSim();
}
function sim(dt: number): void {
if (phaseRef.current === 1) {
shineTRef.current += dt;
if (shineTRef.current > 950) resetSim();
return;
}
const quota = (sps * dt) / 1000;
let stuck = 0, steps = 0;
const maxSteps = (dt / 16.7) * WALKERS * 9;
const walkers = walkersRef.current;
while (stuck < quota && steps < maxSteps) {
for (let i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota || steps >= maxSteps) break;
}
}
if (maxRRef.current > n - 2) { phaseRef.current = 1; shineTRef.current = 0; }
}
function stamp(px: number, py: number, R: number, G: number, B: number, a: number): void {
const img = imgRef.current;
const { w: W, h: H } = sizeRef.current;
if (!img) return;
const x = px | 0, y = py | 0;
if (x < 0 || x >= W || y < 0 || y >= H) return;
const j = (y * W + x) << 2, d = img.data;
d[j] = d[j] * (1 - a) + R * a;
d[j + 1] = d[j + 1] * (1 - a) + G * a;
d[j + 2] = d[j + 2] * (1 - a) + B * a;
}
function stampSoft(px: number, py: number, R: number, G: number, B: number, a: number): void {
const col = colRef.current;
if (!col) return;
const x = px | 0, y = py | 0;
for (let yy = y - 2; yy <= y + 2; yy++)
for (let xx = x - 2; xx <= x + 2; xx++)
stamp(xx, yy, col.halo[0], col.halo[1], col.halo[2], a * 0.10);
for (let y2 = y - 1; y2 <= y + 1; y2++)
for (let x2 = x - 1; x2 <= x + 1; x2++)
stamp(x2, y2, R, G, B, a * 0.42);
stamp(x, y, R, G, B, Math.min(1, a));
}
function compose(): void {
const ctx = ctxRef.current;
const img = imgRef.current;
const col = colRef.current;
const lut = lutRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !img || !col || !lut) return;
const d = img.data;
const fr = frRef.current;
const topR = col.ink[0] + 6, topG = col.ink[1] + 6, topB = col.ink[2] + 9;
const botR = col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.34, botG = col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.34, botB = col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.34;
const a1x = W * (0.18 + 0.10 * Math.sin(fr * 0.011)), a1y = H * (0.24 + 0.12 * Math.cos(fr * 0.013));
const a2x = W * (0.84 + 0.09 * Math.cos(fr * 0.009)), a2y = H * (0.28 + 0.10 * Math.sin(fr * 0.015));
const a3x = W * (0.24 + 0.08 * Math.cos(fr * 0.012 + 2)), a3y = H * (0.82 + 0.09 * Math.sin(fr * 0.010 + 1));
const a4x = W * (0.78 + 0.10 * Math.sin(fr * 0.008 + 4)), a4y = H * (0.78 + 0.11 * Math.cos(fr * 0.014 + 3));
const p1 = 0.085 + 0.050 * Math.sin(fr * 0.021);
const p2 = 0.075 + 0.045 * Math.cos(fr * 0.017);
const p3 = 0.070 + 0.040 * Math.sin(fr * 0.019 + 2);
const p4 = 0.080 + 0.048 * Math.cos(fr * 0.016 + 1);
const sig = 1 / (H * H * 0.55);
for (let y = 0; y < H; y++) {
const ty = y / H;
const br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
const row = y * W;
for (let x = 0; x < W; x++) {
const dx1 = x - a1x, dy1 = y - a1y, dx2 = x - a2x, dy2 = y - a2y;
const dx3 = x - a3x, dy3 = y - a3y, dx4 = x - a4x, dy4 = y - a4y;
const g = p1 * Math.exp(-(dx1 * dx1 + dy1 * dy1) * sig)
+ p2 * Math.exp(-(dx2 * dx2 + dy2 * dy2) * sig)
+ p3 * Math.exp(-(dx3 * dx3 + dy3 * dy3) * sig)
+ p4 * Math.exp(-(dx4 * dx4 + dy4 * dy4) * sig);
const j = (row + x) << 2;
d[j] = br + (col.halo[0] - br) * g;
d[j + 1] = bg + (col.halo[1] - bg) * g;
d[j + 2] = bb + (col.halo[2] - bb) * g;
}
}
const cx = W / 2, cy = H / 2;
const scale = (Math.min(W, H) / 2 - 8) / n;
const shineWave = phaseRef.current === 1 ? shineTRef.current * 0.018 : 0;
const cells = cellsRef.current;
const order = orderRef.current;
const gRot = gRotRef.current;
for (let i = 0; i < cells.length; i += 3) {
const th = cells[i], r = cells[i + 1], q = order > 1 ? cells[i + 2] / order : 1;
let li = 0.40 + 0.60 * q;
if (phaseRef.current === 1) li += 0.30 * Math.sin(shineWave - r * 0.22);
if (li < 0) li = 0; else if (li > 1) li = 1;
const ci = (li * 255) | 0;
const R = lut.r[ci], G = lut.g[ci], B = lut.b[ci];
const rr = r * scale;
for (let k = 0; k < COPIES; k++) {
const base = gRot + k * SECTOR;
stampSoft(cx + rr * Math.sin(base + th), cy + rr * Math.cos(base + th), R, G, B, 0.85);
stampSoft(cx + rr * Math.sin(base - th), cy + rr * Math.cos(base - th), R, G, B, 0.85);
}
}
const rand = randRef.current;
const motes = motesRef.current;
for (let m = 0; m < motes.length; m++) {
const mo = motes[m];
const ddx = cx - mo.x, ddy = cy - mo.y, dd = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
const sp = 0.05 + mo.c * 0.05;
mo.x += (ddx / dd) * sp + (rand() - 0.5) * 1.5;
mo.y += (ddy / dd) * sp + (rand() - 0.5) * 1.5;
if (dd < 12) spawnMote(mo, true);
const mc = mo.c < 0.34 ? col.mint : mo.c < 0.67 ? col.sky : col.mist;
const ma = 0.34 + 0.22 * Math.sin(fr * 0.09 + m * 1.7);
stamp(mo.x, mo.y, mc[0], mc[1], mc[2], ma);
stamp(mo.x + 1, mo.y, mc[0], mc[1], mc[2], ma * 0.45);
stamp(mo.x, mo.y + 1, mc[0], mc[1], mc[2], ma * 0.45);
}
ctx.putImageData(img, 0, 0);
}
let prev = 0;
function frame(now: number): void {
const dt = Math.min(50, now - (prev || now));
prev = now;
gRotRef.current += dt * 0.00022;
sim(dt);
compose();
frRef.current++;
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
for (let i = 0; i < 42; i++) sim(33);
gRotRef.current = 0.3;
compose();
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
dusk: tokRGB(root, "--flake-dusk"),
core: tokRGB(root, "--flake-core"),
halo: tokRGB(root, "--flake-halo"),
mint: tokRGB(root, "--flake-mint"),
sky: tokRGB(root, "--flake-sky"),
mist: tokRGB(root, "--flake-mist"),
};
fit();
buildLUT(colRef.current);
function onResize(): void {
fit();
if (colRef.current) buildLUT(colRef.current);
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectorDeg, sps, n]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={22} strokeLinecap="round"><path d="M128 28v200"/><path d="M45 76l166 104"/><path d="M211 76L45 180"/></g></svg>
<span>60도 부채꼴 섹터 하나만 시뮬레이션하고 나머지를 회전·거울 복사합니다 — 계산은 1/6, 대칭은 어긋나지 않는 육각 결정이 자랍니다</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 className={styles.val}>{sectorDeg}°</b> · 도장 <b className={styles.val}>{copiesShown}</b> · 응집 <b className={styles.val}>{spsShown}</b>/초</span>
<span className={styles.metaDim}>캔버스 · rotate · ImageData</span>
</div>
</div>
</main>
);
}04 · The six-fold mirror — fold by 60 degrees and grow six times faster — Measured area 40.29% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Folding the angle before the walk ever leaves the sector
// Fold the angle onto (-SECTOR/2 .. SECTOR/2) on a 60-degree period — rotational folding (C6).
// A step that would exit the wedge is folded back in, so the simulated space is one sector.
function foldTh(th) {
var P = SECTOR, m = (th + P * 0.5) % P;
if (m < 0) m += P;
return m - P * 0.5;
}
// relative coordinates → folded (angle, radius)
function fold(fx, fy) {
var r = Math.sqrt(fx * fx + fy * fy);
var th = r < 1e-9 ? 0 : foldTh(Math.atan2(fx, fy));
return [th, r];
}
function cellOcc(fx, fy) {
var fo = fold(fx, fy);
if (fo[1] > N) return 0;
var gx = AX + Math.round(Math.sin(fo[0]) * fo[1]);
var gy = AY + Math.round(Math.cos(fo[0]) * fo[1]);
if (gx < 1 || gx >= WS - 1 || gy < 1 || gy >= HS - 1) return 0;
return occ[gy * WS + gx];
}
05Frosted window — fractal ice creeping in from the edge
Seed the entire window frame instead of a center point, fold a temperature field that resists near the middle into the sticking probability, and drift each walker roughly half its steps toward the nearest edge — aggregation creeps inward the way real window frost does.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="18" d="M20 20h216v216H20z"/><path fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round" d="M40 40l60 60M216 40l-60 60M40 216l60-60M216 216l-60-60M128 20v70M128 166v70M20 128h70M166 128h70"/></svg>
<span>Seed the whole window-frame edge and lower the temperature toward the center, and the crystal spreads inward in a fractal pattern — just like frost on a windowpane</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Frosted Window Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">grid cells <b class="cd__val" id="cdGrid"></b> · aggregated <b class="cd__val" id="cdRate"></b>/sec</span>
<span class="cd__meta cd__meta--dim">Canvas · ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 서리 낀 창(05). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (창틀 씨앗 DLA 응집 + 온도 필드 확률 + 격자 확대 putImageData)가 만든다.
// 무대는 한겨울 밤잉크. 파스텔 팔레트는 편 319 공통 --flake-* (04 와 동일 값 — 편마다 다시 정하지 않는다).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(04 와 동일) ──
$flake-ink: #20242e;
$flake-dusk: #4a516e;
$flake-core: #f4f0fa;
$flake-halo: #b8cce8;
$flake-mint: #c8e0d8;
$flake-sky: #a4bcd8;
$flake-mist: #dce8f4;
.cd {
--flake-ink: #{$flake-ink};
--flake-dusk: #{$flake-dusk};
--flake-core: #{$flake-core};
--flake-halo: #{$flake-halo};
--flake-mint: #{$flake-mint};
--flake-sky: #{$flake-sky};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-halo; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #20242e;
--flake-dusk: #4a516e;
--flake-core: #f4f0fa;
--flake-halo: #b8cce8;
--flake-mint: #c8e0d8;
--flake-sky: #a4bcd8;
--flake-mist: #dce8f4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #dce8f4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8cce8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #20242e;
box-shadow: inset 0 0 0 1px rgba(220, 232, 244, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b8cce8;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(220, 232, 244, 0.72);
}
.cd__val {
color: #b8cce8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 서리 낀 창(05). 창틀 네 변 전체를 씨앗으로 깔고, 온도 필드(가장자리=따뜻해 잘 얼고 →
// 중심=차가워 얼기 어려운 역방향 그라디언트)를 반영한 확률로 DLA 랜덤워크를 돌린다.
// 워커는 아직 얼지 않은 안쪽 어디서나 출발해 사방으로 걷다가 이미 언 이웃에 닿으면
// (이웃 수·온도로 정해지는 확률로) 얼어붙는다 — 서리가 유리창 가장자리에서 안으로
// 기어드는 진짜 모양이다. grid cells(저해상도) 점유를 ImageData 한 장으로 그려 확대해 붙인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var GW = 72, GH = 45; // grid cells(480×300 과 같은 8:5 비율) — 커질수록 서리 결이 촘촘해진다
var SPS = 2600; // 초당 응집 셀 수 — 변형 "번지는 속도" 축(1400 → 3600)
var COLD = 0.34; // 중심 냉기 계수 — 클수록 중심까지 다 얼기 전에 가지가 가늘어진다
var BIAS = 0.55; // 온도 필드 드리프트 — 걸음이 가장 가까운 창틀 쪽으로 쏠리는 비율(0.55 = 절반 조금 넘게)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdGrid').textContent = GW + '×' + GH;
document.getElementById('cdRate').textContent = String(SPS);
var SEED = 0x3190405;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
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_INK = tokRGB('--flake-ink');
var C_DUSK = tokRGB('--flake-dusk');
var C_CORE = tokRGB('--flake-core');
var C_HALO = tokRGB('--flake-halo');
var C_MIST = tokRGB('--flake-mist');
// ── grid cells 점유 — 네 변 전체가 씨앗, 중심 냉기 필드에 따라 안쪽으로 자란다 ──
var occ = new Uint8Array(GW * GH);
var noise = new Float32Array(GW * GH); // 셀마다 고정 반짝임 계수(정적 프리컴퓨트) — 얼음 결 질감
var cx0 = (GW - 1) / 2, cy0 = (GH - 1) / 2;
var maxDist = Math.sqrt(cx0 * cx0 + cy0 * cy0);
var walkers = [];
var WALKERS = 60;
var filled = 0, TOTAL = GW * GH;
var phase = 0, shineT = 0;
function idx(gx, gy) { return gy * GW + gx; }
function edgeDist(gx, gy) { return Math.min(gx, gy, GW - 1 - gx, GH - 1 - gy); }
function respawn(w) {
var tries = 0;
do {
w.gx = 1 + ((rand() * (GW - 2)) | 0);
w.gy = 1 + ((rand() * (GH - 2)) | 0);
tries++;
} while (occ[idx(w.gx, w.gy)] && tries < 40);
}
var DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
// 방향 index: 0=+x(DX[0]=1) · 1=-x(DX[1]=-1) · 2=+y(DY[2]=1) · 3=-y(DY[3]=-1)
function stepWalker(w) {
var d;
if (rand() < BIAS) {
// 온도 필드 드리프트 — 가장 가까운 창틀로 한 걸음(방향 index: 0=+x,1=-x,2=+y,3=-y)
var dl = w.gx, dr = GW - 1 - w.gx, dt = w.gy, db = GH - 1 - w.gy;
var m = Math.min(dl, dr, dt, db);
d = m === dl ? 1 : m === dr ? 0 : m === dt ? 3 : 2;
} else {
d = (rand() * 4) | 0; // 무작위 걸음 — 이게 없으면 직선으로만 얼어 가지가 안 갈라진다
}
var nx = w.gx + DX[d], ny = w.gy + DY[d];
if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) { respawn(w); return false; }
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (nx > 0 && occ[idx(nx - 1, ny)] ? 1 : 0) + (nx < GW - 1 && occ[idx(nx + 1, ny)] ? 1 : 0) +
(ny > 0 && occ[idx(nx, ny - 1)] ? 1 : 0) + (ny < GH - 1 && occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 온도 필드 — 중심에 가까울수록(dist 작을수록) 차가워 얼기 어렵다(가지가 가늘어진다)
var dist = Math.min(1, edgeDist(nx, ny) / maxDist);
var temp = 1 - dist * COLD;
var p = (nc === 1 ? 1.0 : nc === 2 ? 0.58 : nc === 3 ? 0.24 : 0.12) * temp;
if (rand() >= p) return false;
occ[i] = 1;
filled++;
respawn(w);
return true;
}
function resetSim() {
occ.fill(0);
for (var x = 0; x < GW; x++) { occ[idx(x, 0)] = 1; occ[idx(x, GH - 1)] = 1; }
for (var y = 0; y < GH; y++) { occ[idx(0, y)] = 1; occ[idx(GW - 1, y)] = 1; }
filled = 2 * (GW + GH) - 4;
phase = 0; shineT = 0;
walkers = [];
for (var i = 0; i < WALKERS; i++) { var w = { gx: 1, gy: 1 }; respawn(w); walkers.push(w); }
}
for (var ni = 0; ni < noise.length; ni++) noise[ni] = rand();
resetSim();
function sim(dt) {
if (phase === 1) {
shineT += dt;
if (shineT > 650) resetSim();
return;
}
var quota = SPS * dt / 1000;
var stuck = 0, steps = 0, maxSteps = 400000; // 순수 배열 연산이라 프레임당 이 정도는 가볍다
var t0 = performance.now(), budgetMs = 9; // 시간 예산으로 자른다 — 워커 수 공식보다 실측이 정확하다
while (stuck < quota && steps < maxSteps) {
for (var i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (filled > TOTAL * 0.62) { phase = 1; shineT = 0; } // 다 자라면 잠깐 반짝이고 리셋
}
var W = 0, H = 0, img = null, gimg = null, gctx = null, gcanvas = null;
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
gcanvas = document.createElement('canvas');
gcanvas.width = GW; gcanvas.height = GH;
gctx = gcanvas.getContext('2d');
gimg = gctx.createImageData(GW, GH);
var gd = gimg.data;
for (var j = 3; j < gd.length; j += 4) gd[j] = 255;
prngState = SEED >>> 0;
resetSim();
}
// grid cells 점유 → 작은 캔버스에 ImageData 로 그리고, 큰 캔버스에 확대해 putImageData 결과를 그린다
function drawGrid() {
var gd = gimg.data;
for (var gy = 0; gy < GH; gy++) {
for (var gx = 0; gx < GW; gx++) {
var i = gy * GW + gx, j = i << 2;
if (!occ[i]) { gd[j] = C_INK[0]; gd[j + 1] = C_INK[1]; gd[j + 2] = C_INK[2]; continue; }
var dist = Math.min(1, edgeDist(gx, gy) / maxDist);
var wave = Math.sin(fr * 0.11 - dist * 5.2 + noise[i] * 6.283); // 결정을 타고 도는 결빛 파동(나무결 반짝임) — 여러 파가 겹쳐 자리마다 다른 박자로 밝아진다
var sparkle = 0.36 + 0.28 * noise[i] + 0.36 * (0.5 + 0.5 * wave);
var t = Math.min(1, dist * 1.15) * sparkle;
var r0 = C_HALO[0] + (C_CORE[0] - C_HALO[0]) * t, g0 = C_HALO[1] + (C_CORE[1] - C_HALO[1]) * t, b0 = C_HALO[2] + (C_CORE[2] - C_HALO[2]) * t;
gd[j] = r0; gd[j + 1] = g0; gd[j + 2] = b0;
}
}
gctx.putImageData(gimg, 0, 0);
}
var fr = 0;
function compose(now) {
var d = img.data;
// ① 유리창 배경 — 세로 그라디언트 + 숨쉬는 성에 안개(가장자리 밝게)
var topR = C_INK[0] + 4, topG = C_INK[1] + 4, topB = C_INK[2] + 8;
var botR = C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.30, botG = C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.30, botB = C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.30;
var breathe = 0.5 + 0.5 * Math.sin(fr * 0.05);
for (var y = 0; y < H; y++) {
var ty = y / H;
var br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
var edgeY = Math.min(y, H - 1 - y) / (H * 0.5);
var row = y * W;
for (var x = 0; x < W; x++) {
var edgeX = Math.min(x, W - 1 - x) / (W * 0.5);
var edge = 1 - Math.min(edgeX, edgeY);
var vig = Math.pow(Math.max(0, edge), 2.2) * (0.10 + 0.05 * breathe);
var j = (row + x) << 2;
d[j] = br + (C_MIST[0] - br) * vig;
d[j + 1] = bg + (C_MIST[1] - bg) * vig;
d[j + 2] = bb + (C_MIST[2] - bb) * vig;
}
}
// ② 서리 결정 — grid cells 이미지를 확대해 얹는다(가까운 이웃 보간 없이 또렷하게)
drawGrid();
var sx = W / GW, sy = H / GH;
for (var gy2 = 0; gy2 < GH; gy2++) {
var gy0 = (gy2 * sy) | 0, gy1 = ((gy2 + 1) * sy) | 0;
for (var gx2 = 0; gx2 < GW; gx2++) {
var gi = (gy2 * GW + gx2) << 2;
if (gimg.data[gi] === C_INK[0] && gimg.data[gi + 1] === C_INK[1] && gimg.data[gi + 2] === C_INK[2]) continue;
var R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
var gx0 = (gx2 * sx) | 0, gx1 = ((gx2 + 1) * sx) | 0;
for (var yy = gy0; yy < gy1 && yy < H; yy++) {
var rowo = yy * W;
for (var xx = gx0; xx < gx1 && xx < W; xx++) {
var jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 창을 닦고 지나가는 결빛 스윕 — 자란 뒤에도 화면이 계속 산다(성에 위 대각선 하이라이트)
for (var sw = 0; sw < 2; sw++) { // 두 줄기가 반 cycle 어긋나게 — 쉬는 구간 없다
var ph = ((now * 0.0013) % 1 + 0.5 * sw) % 1; // 0→1 순환(≈0.77초 cycle)
var cx = (ph * 1.7 - 0.35) * W, cy = (-0.15 + ph * 0.5) * H;
var rr = W * 0.34, r2 = rr * rr;
var x0 = Math.max(0, (cx - rr) | 0), x1 = Math.min(W - 1, (cx + rr) | 0);
var y0 = Math.max(0, (cy - rr) | 0), y1 = Math.min(H - 1, (cy + rr) | 0);
if (x1 < x0 || y1 < y0) continue;
var pd = ctx.getImageData(x0, y0, x1 - x0 + 1, y1 - y0 + 1);
var pdd = pd.data, pw = x1 - x0 + 1;
for (var yy = y0; yy <= y1; yy++) {
var dyy = yy - cy;
for (var xx = x0; xx <= x1; xx++) {
var dxx = xx - cx;
var q = (dxx * dxx + dyy * dyy) / r2;
if (q > 1) continue;
var a = Math.pow(1 - q, 2) * 0.16; // 부드러운 가우시안 느낌의 산
var pj = ((yy - y0) * pw + (xx - x0)) << 2;
pdd[pj] += (C_CORE[0] - pdd[pj]) * a;
pdd[pj + 1] += (C_CORE[1] - pdd[pj + 1]) * a;
pdd[pj + 2] += (C_CORE[2] - pdd[pj + 2]) * a;
}
}
ctx.putImageData(pd, x0, y0);
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
sim(dt);
compose(now);
fr++;
raf = requestAnimationFrame(frame);
}
function stillDraw() {
fit();
for (var i = 0; i < 30; i++) sim(33);
compose(0);
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FrostWindowPane — vanilla 판(run/319/demo/05-snowflake-frost-window-pane/index.html) 의 React 포트.
// 창틀 네 변 전체를 씨앗으로 깔고 온도 필드(가장자리=잘 얼고 → 중심=얼기 어려운 역방향 그라디언트)를
// 확률에 반영한 DLA 랜덤워크 — 서리가 유리창 가장자리에서 안쪽으로 프랙탈로 기어드는 진짜 모양.
// 격자(저해상도) 점유를 ImageData 한 장에 그려 확대해 붙이고 결빛 스윕이 지나간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FrostWindowPane.module.scss";
export type FrostWindowPaneProps = {
duration?: string;
easing?: string;
color?: string;
gw?: number; // 격자 폭(셀 수) — 변형 "결의 촘촘함" 축(72 ↔ 96)
sps?: number; // 초당 응집 셀 수 — 변형 "번지는 속도" 축(1400 ↔ 3600)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Walker = { gx: number; gy: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; halo: RGB; mist: RGB };
const FIELD_LABEL = "서리 낀 창 실험판";
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function FrostWindowPane({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b8cce8",
gw = 72,
sps = 2600,
}: FrostWindowPaneProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gimgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gctxRef = useRef<CanvasRenderingContext2D | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3190405)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [gridShown] = useState(`${gw}×${Math.round(gw * 0.625)}`);
const [spsShown] = useState(sps);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// ── 서리 상태(ref 로 보관 — rAF 루프에서만 갱신, 리렌더 유발 안 함) ──
const GH = Math.round(gw * 0.625);
const COLD = 0.34;
const BIAS = 0.55;
const occRef = useRef<Uint8Array>(new Uint8Array(gw * GH)) as Box<Uint8Array>;
const noiseRef = useRef<Float32Array>(new Float32Array(gw * GH)) as Box<Float32Array>;
const filledRef = useRef(0) as Box<number>;
const phaseRef = useRef(0) as Box<number>;
const shineTRef = useRef(0) as Box<number>;
const walkersRef = useRef<Walker[]>([]) as Box<Walker[]>;
const frRef = useRef(0) as Box<number>;
const maxDistRef = useRef(1) as Box<number>;
const WALKERS = 60;
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 idx(gx: number, gy: number): number {
return gy * gw + gx;
}
function edgeDist(gx: number, gy: number): number {
return Math.min(gx, gy, gw - 1 - gx, GH - 1 - gy);
}
function respawn(w: Walker): void {
const rand = randRef.current;
let tries = 0;
do {
w.gx = 1 + ((rand() * (gw - 2)) | 0);
w.gy = 1 + ((rand() * (GH - 2)) | 0);
tries++;
} while (occRef.current[idx(w.gx, w.gy)] && tries < 40);
}
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
// 방향 index: 0=+x · 1=-x · 2=+y · 3=-y
function stepWalker(w: Walker): boolean {
const rand = randRef.current;
const occ = occRef.current;
let d: number;
if (rand() < BIAS) {
// 온도 필드 드리프트 — 가장 가까운 창틀로 한 걸음
const dl = w.gx, dr = gw - 1 - w.gx, dt = w.gy, db = GH - 1 - w.gy;
const m = Math.min(dl, dr, dt, db);
d = m === dl ? 1 : m === dr ? 0 : m === dt ? 3 : 2;
} else {
d = (rand() * 4) | 0; // 무작위 걸음 — 이게 없으면 가지가 안 갈라진다
}
const nx = w.gx + DX[d], ny = w.gy + DY[d];
if (nx < 0 || nx >= gw || ny < 0 || ny >= GH) { respawn(w); return false; }
w.gx = nx; w.gy = ny;
const i = idx(nx, ny);
if (occ[i]) return false;
const nc = (nx > 0 && occ[idx(nx - 1, ny)] ? 1 : 0) + (nx < gw - 1 && occ[idx(nx + 1, ny)] ? 1 : 0) +
(ny > 0 && occ[idx(nx, ny - 1)] ? 1 : 0) + (ny < GH - 1 && occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 온도 필드 — 중심에 가까울수록 차가워 얼기 어렵다
const dist = Math.min(1, edgeDist(nx, ny) / maxDistRef.current);
const temp = 1 - dist * COLD;
const p = (nc === 1 ? 1.0 : nc === 2 ? 0.58 : nc === 3 ? 0.24 : 0.12) * temp;
if (rand() >= p) return false;
occ[i] = 1;
filledRef.current++;
respawn(w);
return true;
}
function resetSim(): void {
const occ = occRef.current;
occ.fill(0);
for (let x = 0; x < gw; x++) { occ[idx(x, 0)] = 1; occ[idx(x, GH - 1)] = 1; }
for (let y = 0; y < GH; y++) { occ[idx(0, y)] = 1; occ[idx(gw - 1, y)] = 1; }
filledRef.current = 2 * (gw + GH) - 4;
phaseRef.current = 0;
shineTRef.current = 0;
const ws: Walker[] = [];
for (let i = 0; i < WALKERS; i++) { const w = { gx: 1, gy: 1 }; respawn(w); ws.push(w); }
walkersRef.current = ws;
}
function sim(dt: number): void {
if (phaseRef.current === 1) {
shineTRef.current += dt;
if (shineTRef.current > 650) resetSim();
return;
}
const quota = sps * dt / 1000;
let stuck = 0, steps = 0;
const maxSteps = 400000;
const t0 = performance.now(), budgetMs = 9;
while (stuck < quota && steps < maxSteps) {
const ws = walkersRef.current;
for (let i = 0; i < ws.length; i++) {
if (stepWalker(ws[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (filledRef.current > gw * GH * 0.62) { phaseRef.current = 1; shineTRef.current = 0; }
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(W, H);
for (let i = 3; i < img.data.length; i += 4) img.data[i] = 255;
imgRef.current = img;
const gcanvas = document.createElement("canvas");
gcanvas.width = gw; gcanvas.height = GH;
const gctx = gcanvas.getContext("2d");
if (!gctx) return;
gctxRef.current = gctx;
const gimg = gctx.createImageData(gw, GH);
for (let j = 3; j < gimg.data.length; j += 4) gimg.data[j] = 255;
gimgRef.current = gimg;
const cx0 = (gw - 1) / 2, cy0 = (GH - 1) / 2;
maxDistRef.current = Math.sqrt(cx0 * cx0 + cy0 * cy0);
randRef.current = makePRNG(0x3190405);
const noise = noiseRef.current;
for (let ni = 0; ni < noise.length; ni++) noise[ni] = randRef.current();
resetSim();
}
// 격자 점유 → 작은 캔버스 ImageData → 큰 캔버스에 확대해 얹는다
function drawGrid(): void {
const gimg = gimgRef.current, occ = occRef.current, noise = noiseRef.current, col = colRef.current;
if (!gimg || !col) return;
const gd = gimg.data;
const fr = frRef.current;
for (let gy = 0; gy < GH; gy++) {
for (let gx = 0; gx < gw; gx++) {
const i = gy * gw + gx, j = i << 2;
if (!occ[i]) { gd[j] = col.ink[0]; gd[j + 1] = col.ink[1]; gd[j + 2] = col.ink[2]; continue; }
const dist = Math.min(1, edgeDist(gx, gy) / maxDistRef.current);
const wave = Math.sin(fr * 0.11 - dist * 5.2 + noise[i] * 6.283); // 결빛 파동 — 자리마다 다른 박자
const sparkle = 0.36 + 0.28 * noise[i] + 0.36 * (0.5 + 0.5 * wave);
const t = Math.min(1, dist * 1.15) * sparkle;
gd[j] = col.halo[0] + (col.core[0] - col.halo[0]) * t;
gd[j + 1] = col.halo[1] + (col.core[1] - col.halo[1]) * t;
gd[j + 2] = col.halo[2] + (col.core[2] - col.halo[2]) * t;
}
}
if (gctxRef.current) gctxRef.current.putImageData(gimg, 0, 0);
}
function compose(now: number): void {
const ctx = ctxRef.current, img = imgRef.current, col = colRef.current;
if (!ctx || !img || !col) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const d = img.data;
// ① 유리창 배경 — 세로 그라디언트 + 숨쉬는 성에 안개
const topR = col.ink[0] + 4, topG = col.ink[1] + 4, topB = col.ink[2] + 8;
const botR = col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.30, botG = col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.30, botB = col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.30;
const breathe = 0.5 + 0.5 * Math.sin(frRef.current * 0.05);
for (let y = 0; y < H; y++) {
const ty = y / H;
const br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
const edgeY = Math.min(y, H - 1 - y) / (H * 0.5);
const row = y * W;
for (let x = 0; x < W; x++) {
const edgeX = Math.min(x, W - 1 - x) / (W * 0.5);
const edge = 1 - Math.min(edgeX, edgeY);
const vig = Math.pow(Math.max(0, edge), 2.2) * (0.10 + 0.05 * breathe);
const j = (row + x) << 2;
d[j] = br + (col.mist[0] - br) * vig;
d[j + 1] = bg + (col.mist[1] - bg) * vig;
d[j + 2] = bb + (col.mist[2] - bb) * vig;
}
}
// ② 서리 결정 — 격자 이미지를 확대해 얹는다
drawGrid();
const gimg = gimgRef.current;
if (gimg) {
const sx = W / gw, sy = H / GH;
for (let gy2 = 0; gy2 < GH; gy2++) {
const gy0 = (gy2 * sy) | 0, gy1 = ((gy2 + 1) * sy) | 0;
for (let gx2 = 0; gx2 < gw; gx2++) {
const gi = (gy2 * gw + gx2) << 2;
if (gimg.data[gi] === col.ink[0] && gimg.data[gi + 1] === col.ink[1] && gimg.data[gi + 2] === col.ink[2]) continue;
const R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
const gx0 = (gx2 * sx) | 0, gx1 = ((gx2 + 1) * sx) | 0;
for (let yy = gy0; yy < gy1 && yy < H; yy++) {
const rowo = yy * W;
for (let xx = gx0; xx < gx1 && xx < W; xx++) {
const jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 창을 닦고 지나가는 결빛 스윕 — 자란 뒤에도 화면이 계속 산다
for (let sw = 0; sw < 2; sw++) {
const ph = ((now * 0.0013) % 1 + 0.5 * sw) % 1;
const cx = (ph * 1.7 - 0.35) * W, cy = (-0.15 + ph * 0.5) * H;
const rr = W * 0.34, r2 = rr * rr;
const x0 = Math.max(0, (cx - rr) | 0), x1 = Math.min(W - 1, (cx + rr) | 0);
const y0 = Math.max(0, (cy - rr) | 0), y1 = Math.min(H - 1, (cy + rr) | 0);
if (x1 < x0 || y1 < y0) continue;
const pd = ctx.getImageData(x0, y0, x1 - x0 + 1, y1 - y0 + 1);
const pdd = pd.data, pw = x1 - x0 + 1;
for (let yy = y0; yy <= y1; yy++) {
const dyy = yy - cy;
for (let xx = x0; xx <= x1; xx++) {
const dxx = xx - cx;
const q = (dxx * dxx + dyy * dyy) / r2;
if (q > 1) continue;
const a = Math.pow(1 - q, 2) * 0.16;
const pj = ((yy - y0) * pw + (xx - x0)) << 2;
pdd[pj] += (col.core[0] - pdd[pj]) * a;
pdd[pj + 1] += (col.core[1] - pdd[pj + 1]) * a;
pdd[pj + 2] += (col.core[2] - pdd[pj + 2]) * a;
}
}
ctx.putImageData(pd, x0, y0);
}
}
const prevRef = useRef(0) as Box<number>;
function frame(now: number): void {
const dt = Math.min(50, now - (prevRef.current || now));
prevRef.current = now;
sim(dt);
compose(now);
frRef.current++;
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
for (let i = 0; i < 30; i++) sim(33);
compose(0);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
dusk: tokRGB(root, "--flake-dusk"),
core: tokRGB(root, "--flake-core"),
halo: tokRGB(root, "--flake-halo"),
mist: tokRGB(root, "--flake-mist"),
};
fit();
function onResize(): void {
fit();
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gw, sps]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><path fill="none" stroke="currentColor" strokeWidth={18} d="M20 20h216v216H20z"/><path fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round" d="M40 40l60 60M216 40l-60 60M40 216l60-60M216 216l-60-60M128 20v70M128 166v70M20 128h70M166 128h70"/></svg>
<span>씨앗을 창틀 가장자리 전체에 깔고 온도 필드를 안쪽으로 낮추면, 응집이 창가 서리처럼 가장자리에서 안쪽으로 프랙탈 무늬로 뻗어갑니다</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 className={styles.val}>{gridShown}</b> · 응집 <b className={styles.val}>{spsShown}</b>/초</span>
<span className={styles.metaDim}>캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>
);
}05 · Frosted window — fractal ice creeping inward from the frame edge — Measured area 45.515% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
BIAS drifts the walk, COLD resists the center
function stepWalker(w) {
var d;
if (rand() < BIAS) {
// temperature-field drift — one step toward the nearest frame edge (direction index: 0=+x,1=-x,2=+y,3=-y)
var dl = w.gx, dr = GW - 1 - w.gx, dt = w.gy, db = GH - 1 - w.gy;
var m = Math.min(dl, dr, dt, db);
d = m === dl ? 1 : m === dr ? 0 : m === dt ? 3 : 2;
} else {
d = (rand() * 4) | 0; // a random step — without this, freezing runs in straight lines and never branches
}
var nx = w.gx + DX[d], ny = w.gy + DY[d];
if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) { respawn(w); return false; }
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (nx > 0 && occ[idx(nx - 1, ny)] ? 1 : 0) + (nx < GW - 1 && occ[idx(nx + 1, ny)] ? 1 : 0) +
(ny > 0 && occ[idx(nx, ny - 1)] ? 1 : 0) + (ny < GH - 1 && occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// temperature field — closer to center (smaller dist) freezes less readily, thinning the branches
var dist = Math.min(1, edgeDist(nx, ny) / maxDist);
var temp = 1 - dist * COLD;
var p = (nc === 1 ? 1.0 : nc === 2 ? 0.58 : nc === 3 ? 0.24 : 0.12) * temp;
if (rand() >= p) return false;
06Growth-ring coloring — etching contact order as pastel rings
Nothing about the sticking rule changes here — the only new step is recording the order a cell joined the crystal, then coloring by that order through a pastel ramp, so growth reads like tree rings, cells that stuck at the same moment sharing a band.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><circle cx="128" cy="128" r="16" fill="none" stroke="currentColor" stroke-width="16"/><circle cx="128" cy="128" r="48" fill="none" stroke="currentColor" stroke-width="12" opacity=".75"/><circle cx="128" cy="128" r="80" fill="none" stroke="currentColor" stroke-width="10" opacity=".5"/><circle cx="128" cy="128" r="108" fill="none" stroke="currentColor" stroke-width="8" opacity=".3"/></svg>
<span>Record the moment each particle attaches as a number and color it by age, and the crystal's growth rings read like tree rings — pastel concentric circles</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Crystal Lattice Coloring Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">growth rings <b class="cd__val" id="cdRings"></b> · aggregated <b class="cd__val" id="cdRate"></b>/sec</span>
<span class="cd__meta cd__meta--dim">Canvas · ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 결정 격자 채색(06). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (나이(접촉 순번) 기록 DLA + 나이테 파스텔 램프 LUT + 눈가루 부유)가 만든다.
// 무대는 한겨울 밤잉크. 파스텔 팔레트는 편 319 공통 --flake-* (04·05 와 동일 값).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(04·05 와 동일) ──
$flake-ink: #20242e;
$flake-dusk: #4a516e;
$flake-core: #f4f0fa;
$flake-halo: #b8cce8;
$flake-mint: #c8e0d8;
$flake-sky: #a4bcd8;
$flake-mist: #dce8f4;
.cd {
--flake-ink: #{$flake-ink};
--flake-dusk: #{$flake-dusk};
--flake-core: #{$flake-core};
--flake-halo: #{$flake-halo};
--flake-mint: #{$flake-mint};
--flake-sky: #{$flake-sky};
--flake-mist: #{$flake-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-mist, .72); }
.cd__val { color: $flake-halo; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #20242e;
--flake-dusk: #4a516e;
--flake-core: #f4f0fa;
--flake-halo: #b8cce8;
--flake-mint: #c8e0d8;
--flake-sky: #a4bcd8;
--flake-mist: #dce8f4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #dce8f4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8cce8;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #20242e;
box-shadow: inset 0 0 0 1px rgba(220, 232, 244, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b8cce8;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(220, 232, 244, 0.72);
}
.cd__val {
color: #b8cce8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 결정 grid cells 채색(06). DLA 는 붙는 순간을 기록한다 — order 배열에 셀 인덱스를 stick 순서대로 쌓고,
// 그려질 때 나이(order / 지금까지 총 응집)를 8단 파스텔 램프로 읽는다. 같은 시각에 붙은 셀은
// 같은 나이 → 결정 안에 나무 나이테 같은 동심원이 새겨진다. 성장이 다 차면 반짝 회전 후 리셋.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 46; // grid cells 반경(셀 수) — 커질수록 나이테가 촘촘해진다
var SPS = 300; // 초당 응집 입자 수 — 변형 "응집 속도" 축(90 → 700)
var RINGS = 8; // growth rings 단수 — 나이를 몇 개의 파스텔 띠로 나눌지(6 → 12)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdRings').textContent = String(RINGS);
document.getElementById('cdRate').textContent = String(SPS);
var SEED = 0x3190406;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
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_INK = tokRGB('--flake-ink');
var C_DUSK = tokRGB('--flake-dusk');
var C_CORE = tokRGB('--flake-core');
var C_HALO = tokRGB('--flake-halo');
var C_MINT = tokRGB('--flake-mint');
var C_SKY = tokRGB('--flake-sky');
var C_MIST = tokRGB('--flake-mist');
// ── grid cells — 중앙 씨앗 하나에서 자라는 방사 DLA ──
var GW = 2 * N + 8, GH = GW; // 정사각 grid cells, 중심 = 씨앗
var CX = (GW / 2) | 0, CY = CX;
var occ = new Uint8Array(GW * GH);
var age = new Int32Array(GW * GH); // 셀별 나이(stick 순서, 1부터 — 0=빈칸)
var order = []; // stick 순서대로 셀 인덱스를 쌓는다(나이테의 역사)
var walkers = [];
var WALKERS = 40;
var phase = 0, shineT = 0;
function idx(gx, gy) { return gy * GW + gx; }
function respawn(w) { // 씨앗 근처 구름에서 출발 — 멀리서 오면 프레임 예산이 새는 곳으로 걸음 낭비
var a = rand() * 6.283, r = 6 + rand() * Math.min(10, N * 0.35);
w.gx = CX + Math.round(Math.cos(a) * r);
w.gy = CY + Math.round(Math.sin(a) * r);
if (w.gx < 1 || w.gx >= GW - 1 || w.gy < 1 || w.gy >= GH - 1) { w.gx = CX + 2; w.gy = CY; }
}
var DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w) {
var d = (rand() * 4) | 0;
var nx = w.gx + DX[d], ny = w.gy + DY[d];
var dx = nx - CX, dy = ny - CY;
if (dx * dx + dy * dy > N * N) { respawn(w); return false; } // grid cells 밖 → 구름으로 되돌린다
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (occ[idx(nx - 1, ny)] ? 1 : 0) + (occ[idx(nx + 1, ny)] ? 1 : 0) +
(occ[idx(nx, ny - 1)] ? 1 : 0) + (occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 가지 확률 — 이웃이 적게 닿은 외곽일수록 잘 붙는다(DLA 수지상 편향)
var p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) return false;
occ[i] = 1;
order.push(i);
age[i] = order.length; // 나이 = 붙은 순번 — 색의 유일한 출처
respawn(w);
return true;
}
function resetSim() {
occ.fill(0); age.fill(0); order = [];
occ[idx(CX, CY)] = 1; order.push(idx(CX, CY)); age[idx(CX, CY)] = 1;
phase = 0; shineT = 0;
walkers = [];
for (var i = 0; i < WALKERS; i++) { var w = { gx: CX, gy: CY }; respawn(w); walkers.push(w); }
}
resetSim();
function sim(dt) {
if (phase === 1) {
shineT += dt;
if (shineT > 1200) resetSim(); // 완성 결정을 충분히 보여 준 다음 다음 세대
return;
}
var quota = SPS * dt / 1000;
var stuck = 0, steps = 0, maxSteps = 500000;
var t0 = performance.now(), budgetMs = 9;
while (stuck < quota && steps < maxSteps) {
for (var i = 0; i < walkers.length; i++) {
if (stepWalker(walkers[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (order.length > SPS * 14) { phase = 1; shineT = 0; } // 약 14초 치 결정이면 완성
}
var W = 0, H = 0, img = null, gimg = null, gctx = null, gcanvas = null;
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
img = ctx.createImageData(W, H);
var d = img.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
gcanvas = document.createElement('canvas');
gcanvas.width = GW; gcanvas.height = GH;
gctx = gcanvas.getContext('2d');
gimg = gctx.createImageData(GW, GH);
var gd = gimg.data;
for (var j = 3; j < gd.length; j += 4) gd[j] = 255;
prngState = SEED >>> 0;
resetSim();
}
// 나이 → 파스텔 램프 LUT(256단계) — 나이테 RINGS 개의 띠를 지나며 색이 바뀐다
var lut = { r: new Uint8ClampedArray(256), g: new Uint8ClampedArray(256), b: new Uint8ClampedArray(256) };
function buildLUT() {
// 띠 경계마다 살짝 밝은 테(나이테 선) — 같은 나이끼리 묶여 동심원으로 보인다
var cs = [C_DUSK, C_DUSK, C_HALO, C_MINT, C_MIST, C_SKY, C_MINT, C_CORE];
for (var i = 0; i < 256; i++) {
var t = i / 255;
var band = Math.min(cs.length - 1, 1 + Math.floor(t * (cs.length - 1)));
var c0 = cs[band - 1], c1 = cs[band];
var f = Math.min(1, t * (cs.length - 1) - (band - 1) * 2 + 1); // 띠 안에서 부드럽게
var ring = t * (cs.length - 1) % 1;
var edge = ring < 0.16 ? 0.35 * (1 - ring / 0.16) : 0; // 띠 시작 = 밝은 나이테 선
var r0 = c0[0] + (c1[0] - c0[0]) * f, g0 = c0[1] + (c1[1] - c0[1]) * f, b0 = c0[2] + (c1[2] - c0[2]) * f;
lut.r[i] = r0 + (C_CORE[0] - r0) * edge;
lut.g[i] = g0 + (C_CORE[1] - g0) * edge;
lut.b[i] = b0 + (C_CORE[2] - b0) * edge;
}
}
buildLUT();
// grid cells → 작은 캔버스 ImageData(나이 색) → 큰 캔버스에 확대
function drawGrid(pulse) {
var gd = gimg.data;
var total = Math.max(2, order.length);
for (var gy = 0; gy < GH; gy++) {
for (var gx = 0; gx < GW; gx++) {
var i = gy * GW + gx, j = i << 2;
var a = age[i];
if (!a) { gd[j] = C_INK[0]; gd[j + 1] = C_INK[1]; gd[j + 2] = C_INK[2]; continue; }
var t = a / total; // 나이 비율(0=첫 씨앗 → 1=방금 붙은 가장자리)
var li = Math.max(0, Math.min(255, (t * 255 * pulse) | 0));
gd[j] = lut.r[li]; gd[j + 1] = lut.g[li]; gd[j + 2] = lut.b[li];
}
}
gctx.putImageData(gimg, 0, 0);
}
var fr = 0;
function compose(now) {
var d = img.data;
// ① 무대 — 세로 그라디언트 + 중앙 성광(결정이 자라는 자리를 은은히 밝힌다)
var topR = C_INK[0] + 3, topG = C_INK[1] + 3, topB = C_INK[2] + 6;
var botR = C_INK[0] + (C_DUSK[0] - C_INK[0]) * 0.34, botG = C_INK[1] + (C_DUSK[1] - C_INK[1]) * 0.34, botB = C_INK[2] + (C_DUSK[2] - C_INK[2]) * 0.34;
var breathe = 0.5 + 0.5 * Math.sin(now * 0.0011);
var gx0 = 0.5 + 0.06 * Math.sin(now * 0.0007), gy0 = 0.5 + 0.05 * Math.cos(now * 0.0009);
for (var y = 0; y < H; y++) {
var ty = y / H;
var br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
var row = y * W, dy = (y / H - gy0) * H;
for (var x = 0; x < W; x++) {
var dx = (x / W - gx0) * W * 1.35;
var q = (dx * dx + dy * dy) / (H * H * 1.6);
var glow = Math.exp(-q) * (0.10 + 0.05 * breathe);
var j = (row + x) << 2;
d[j] = br + (C_MIST[0] - br) * glow;
d[j + 1] = bg + (C_MIST[1] - bg) * glow;
d[j + 2] = bb + (C_MIST[2] - bb) * glow;
}
}
// ② 결정 나이테 — 나이 비율에 살짝 숨쉬는 맥(색 램프 인덱스를 조금씩 흔든다)
var pulse = 0.94 + 0.06 * Math.sin(now * 0.0023);
drawGrid(pulse);
var sx = W / GW, sy = H / GH;
var ox = ((W - GW * sx) / 2) | 0, oy = ((H - GH * sy) / 2) | 0;
for (var gy2 = 0; gy2 < GH; gy2++) {
var yA = oy + ((gy2 * sy) | 0), yB = oy + (((gy2 + 1) * sy) | 0);
for (var gx2 = 0; gx2 < GW; gx2++) {
var gi = (gy2 * GW + gx2) << 2;
if (gimg.data[gi] === C_INK[0] && gimg.data[gi + 1] === C_INK[1] && gimg.data[gi + 2] === C_INK[2]) continue;
var R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
var xA = ox + ((gx2 * sx) | 0), xB = ox + (((gx2 + 1) * sx) | 0);
for (var yy = Math.max(0, yA); yy < Math.min(H, yB); yy++) {
var rowo = yy * W;
for (var xx = Math.max(0, xA); xx < Math.min(W, xB); xx++) {
var jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 떠다니는 눈가루 — 결정 위를 파스텔 가루가 느리게 흘러 화면이 쉬지 않는다
for (var m = 0; m < 26; m++) {
var ph = (m / 26 + now * 0.00006 * (1 + (m % 3) * 0.4)) % 1;
var mx = (0.12 + 0.76 * ((m * 0.137 + now * 0.00004 * (1 + (m % 5) * 0.3)) % 1)) * W;
var my = ph * H;
var mr = 1 + (m % 3), ma = 0.24 + 0.16 * Math.sin(now * 0.003 + m);
ctx.fillStyle = 'rgba(' + C_MIST + ',' + ma.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(mx, my, mr, 0, 6.283);
ctx.fill();
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, prev = 0;
function frame(now) {
var dt = Math.min(50, now - (prev || now));
prev = now;
sim(dt);
compose(now);
fr++;
raf = requestAnimationFrame(frame);
}
function stillDraw() {
fit();
for (var i = 0; i < 400; i++) sim(33);
compose(0);
}
window.addEventListener('resize', function () { if (reduce) stillDraw(); });
fit();
if (reduce) {
stillDraw();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// CrystalLatticeColor — vanilla 판(run/319/demo/06-snowflake-crystal-lattice-color/index.html) 의 React 포트.
// DLA 는 입자가 붙는 "순간"을 기록한다 — order 배열에 셀 인덱스를 stick 순서대로 쌓고, 그려질 때
// 나이(순번 / 지금까지 총 응집)를 8단 파스텔 LUT 로 읽는다. 같은 시각에 붙은 셀은 같은 나이 →
// 결정 안에 나무 나이테 같은 동심원이 새겨진다. 격자(저해상도) 점유를 ImageData 한 장에 그려
// 확대해 붙이고, 성장이 다 차면 반짝 회전 후 리셋한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CrystalLatticeColor.module.scss";
export type CrystalLatticeColorProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 격자 반경(셀 수) — 변형 "촘촘함" 축(46 커질수록 나이테가 촘촘해진다)
sps?: number; // 초당 응집 입자 수 — 변형 "응집 속도" 축(90 ↔ 700)
rings?: number; // 연륜 단수 — 나이를 몇 개의 파스텔 띠로 나눌지(6 ↔ 12)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Walker = { gx: number; gy: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; halo: RGB; mint: RGB; sky: RGB; mist: RGB };
const FIELD_LABEL = "결정 격자 채색 실험판";
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function CrystalLatticeColor({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#a4bcd8",
n = 46,
sps = 300,
rings = 8,
}: CrystalLatticeColorProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gimgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const gctxRef = useRef<CanvasRenderingContext2D | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3190406)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [ringsShown] = useState(rings);
const [spsShown] = useState(sps);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// ── 결정 상태(ref 로 보관 — rAF 루프에서만 갱신, 리렌더 유발 안 함) ──
const GW = 2 * n + 8, GH = GW;
const CX = (GW / 2) | 0, CY = CX;
const occRef = useRef<Uint8Array>(new Uint8Array(GW * GH)) as Box<Uint8Array>;
const ageRef = useRef<Int32Array>(new Int32Array(GW * GH)) as Box<Int32Array>; // 셀별 나이(stick 순서, 1부터 — 0=빈칸)
const orderRef = useRef<number[]>([]) as Box<number[]>; // stick 순서대로 셀 인덱스를 쌓는다(나이테의 역사)
const walkersRef = useRef<Walker[]>([]) as Box<Walker[]>;
const phaseRef = useRef(0) as Box<number>;
const shineTRef = useRef(0) as Box<number>;
const frRef = useRef(0) as Box<number>;
const lutRef = useRef<{ r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } | null>(null);
const WALKERS = 40;
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 idx(gx: number, gy: number): number {
return gy * GW + gx;
}
function respawn(w: Walker): void {
const rand = randRef.current;
const a = rand() * 6.283, r = 6 + rand() * Math.min(10, n * 0.35);
w.gx = CX + Math.round(Math.cos(a) * r);
w.gy = CY + Math.round(Math.sin(a) * r);
if (w.gx < 1 || w.gx >= GW - 1 || w.gy < 1 || w.gy >= GH - 1) { w.gx = CX + 2; w.gy = CY; }
}
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
function stepWalker(w: Walker): boolean {
const rand = randRef.current;
const occ = occRef.current;
const age = ageRef.current;
const order = orderRef.current;
const d = (rand() * 4) | 0;
const nx = w.gx + DX[d], ny = w.gy + DY[d];
const dx = nx - CX, dy = ny - CY;
if (dx * dx + dy * dy > n * n) { respawn(w); return false; } // 격자 밖 → 구름으로 되돌린다
w.gx = nx; w.gy = ny;
const i = idx(nx, ny);
if (occ[i]) return false;
const nc = (occ[idx(nx - 1, ny)] ? 1 : 0) + (occ[idx(nx + 1, ny)] ? 1 : 0) +
(occ[idx(nx, ny - 1)] ? 1 : 0) + (occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// 가지 확률 — 이웃이 적게 닿은 외곽일수록 잘 붙는다(DLA 수지상 편향)
const p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) return false;
occ[i] = 1;
order.push(i);
age[i] = order.length; // 나이 = 붙은 순번 — 색의 유일한 출처
respawn(w);
return true;
}
function resetSim(): void {
const occ = occRef.current, age = ageRef.current;
occ.fill(0); age.fill(0); orderRef.current = [];
const order = orderRef.current;
occ[idx(CX, CY)] = 1; order.push(idx(CX, CY)); age[idx(CX, CY)] = 1;
phaseRef.current = 0; shineTRef.current = 0;
const ws: Walker[] = [];
for (let i = 0; i < WALKERS; i++) { const w = { gx: CX, gy: CY }; respawn(w); ws.push(w); }
walkersRef.current = ws;
}
function sim(dt: number): void {
if (phaseRef.current === 1) {
shineTRef.current += dt;
if (shineTRef.current > 1200) resetSim(); // 완성 결정을 충분히 보여 준 다음 다음 세대
return;
}
const quota = sps * dt / 1000;
let stuck = 0, steps = 0;
const maxSteps = 500000;
const t0 = performance.now(), budgetMs = 9;
while (stuck < quota && steps < maxSteps) {
const ws = walkersRef.current;
for (let i = 0; i < ws.length; i++) {
if (stepWalker(ws[i])) stuck++;
steps++;
if (stuck >= quota) break;
}
if ((steps & 2047) === 0 && performance.now() - t0 > budgetMs) break;
}
if (orderRef.current.length > sps * 14) { phaseRef.current = 1; shineTRef.current = 0; } // 약 14초 치 결정이면 완성
}
// 나이 → 파스텔 램프 LUT(256단계) — 나이테 rings 개의 띠를 지나며 색이 바뀐다
function buildLUT(): void {
const col = colRef.current;
if (!col) return;
const lut = {
r: new Uint8ClampedArray(256),
g: new Uint8ClampedArray(256),
b: new Uint8ClampedArray(256),
};
// 띠 경계마다 살짝 밝은 테(나이테 선) — 같은 나이끼리 묶여 동심원으로 보인다
const cs: RGB[] = [col.dusk, col.dusk, col.halo, col.mint, col.mist, col.sky, col.mint, col.core];
for (let i = 0; i < 256; i++) {
const t = i / 255;
const band = Math.min(cs.length - 1, 1 + Math.floor(t * (cs.length - 1)));
const c0 = cs[band - 1], c1 = cs[band];
const f = Math.min(1, t * (cs.length - 1) - (band - 1) * 2 + 1); // 띠 안에서 부드럽게
const ring = (t * (cs.length - 1)) % 1;
const edge = ring < 0.16 ? 0.35 * (1 - ring / 0.16) : 0; // 띠 시작 = 밝은 나이테 선
const r0 = c0[0] + (c1[0] - c0[0]) * f, g0 = c0[1] + (c1[1] - c0[1]) * f, b0 = c0[2] + (c1[2] - c0[2]) * f;
lut.r[i] = r0 + (col.core[0] - r0) * edge;
lut.g[i] = g0 + (col.core[1] - g0) * edge;
lut.b[i] = b0 + (col.core[2] - b0) * edge;
}
lutRef.current = lut;
}
// 격자 → 작은 캔버스 ImageData(나이 색) → 큰 캔버스에 확대
function drawGrid(pulse: number): void {
const gimg = gimgRef.current, col = colRef.current, lut = lutRef.current;
if (!gimg || !col || !lut) return;
const gd = gimg.data;
const age = ageRef.current;
const total = Math.max(2, orderRef.current.length);
for (let gy = 0; gy < GH; gy++) {
for (let gx = 0; gx < GW; gx++) {
const i = gy * GW + gx, j = i << 2;
const a = age[i];
if (!a) { gd[j] = col.ink[0]; gd[j + 1] = col.ink[1]; gd[j + 2] = col.ink[2]; continue; }
const t = a / total; // 나이 비율(0=첫 씨앗 → 1=방금 붙은 가장자리)
const li = Math.max(0, Math.min(255, (t * 255 * pulse) | 0));
gd[j] = lut.r[li]; gd[j + 1] = lut.g[li]; gd[j + 2] = lut.b[li];
}
}
if (gctxRef.current) gctxRef.current.putImageData(gimg, 0, 0);
}
function compose(now: number): void {
const ctx = ctxRef.current, img = imgRef.current, col = colRef.current, gimg = gimgRef.current;
if (!ctx || !img || !col || !gimg) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const d = img.data;
// ① 무대 — 세로 그라디언트 + 중앙 성광(결정이 자라는 자리를 은은히 밝힌다)
const topR = col.ink[0] + 3, topG = col.ink[1] + 3, topB = col.ink[2] + 6;
const botR = col.ink[0] + (col.dusk[0] - col.ink[0]) * 0.34, botG = col.ink[1] + (col.dusk[1] - col.ink[1]) * 0.34, botB = col.ink[2] + (col.dusk[2] - col.ink[2]) * 0.34;
const breathe = 0.5 + 0.5 * Math.sin(now * 0.0011);
const gx0 = 0.5 + 0.06 * Math.sin(now * 0.0007), gy0 = 0.5 + 0.05 * Math.cos(now * 0.0009);
for (let y = 0; y < H; y++) {
const ty = y / H;
const br = topR + (botR - topR) * ty, bg = topG + (botG - topG) * ty, bb = topB + (botB - topB) * ty;
const row = y * W, dy = (y / H - gy0) * H;
for (let x = 0; x < W; x++) {
const dx = (x / W - gx0) * W * 1.35;
const q = (dx * dx + dy * dy) / (H * H * 1.6);
const glow = Math.exp(-q) * (0.10 + 0.05 * breathe);
const j = (row + x) << 2;
d[j] = br + (col.mist[0] - br) * glow;
d[j + 1] = bg + (col.mist[1] - bg) * glow;
d[j + 2] = bb + (col.mist[2] - bb) * glow;
}
}
// ② 결정 나이테 — 나이 비율에 살짝 숨쉬는 맥(색 램프 인덱스를 조금씩 흔든다)
const pulse = 0.94 + 0.06 * Math.sin(now * 0.0023);
drawGrid(pulse);
const sx = W / GW, sy = H / GH;
const ox = ((W - GW * sx) / 2) | 0, oy = ((H - GH * sy) / 2) | 0;
for (let gy2 = 0; gy2 < GH; gy2++) {
const yA = oy + ((gy2 * sy) | 0), yB = oy + (((gy2 + 1) * sy) | 0);
for (let gx2 = 0; gx2 < GW; gx2++) {
const gi = (gy2 * GW + gx2) << 2;
if (gimg.data[gi] === col.ink[0] && gimg.data[gi + 1] === col.ink[1] && gimg.data[gi + 2] === col.ink[2]) continue;
const R = gimg.data[gi], G = gimg.data[gi + 1], B = gimg.data[gi + 2];
const xA = ox + ((gx2 * sx) | 0), xB = ox + (((gx2 + 1) * sx) | 0);
for (let yy = Math.max(0, yA); yy < Math.min(H, yB); yy++) {
const rowo = yy * W;
for (let xx = Math.max(0, xA); xx < Math.min(W, xB); xx++) {
const jj = (rowo + xx) << 2;
d[jj] = R; d[jj + 1] = G; d[jj + 2] = B;
}
}
}
}
ctx.putImageData(img, 0, 0);
// ③ 떠다니는 눈가루 — 결정 위를 파스텔 가루가 느리게 흘러 화면이 쉬지 않는다
for (let m = 0; m < 26; m++) {
const ph = (m / 26 + now * 0.00006 * (1 + (m % 3) * 0.4)) % 1;
const mx = (0.12 + 0.76 * ((m * 0.137 + now * 0.00004 * (1 + (m % 5) * 0.3)) % 1)) * W;
const my = ph * H;
const mr = 1 + (m % 3), ma = 0.24 + 0.16 * Math.sin(now * 0.003 + m);
ctx.fillStyle = `rgba(${col.mist[0]},${col.mist[1]},${col.mist[2]},${ma.toFixed(3)})`;
ctx.beginPath();
ctx.arc(mx, my, mr, 0, 6.283);
ctx.fill();
}
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current = { w: W, h: H };
cv.width = W; cv.height = H;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(W, H);
for (let i = 3; i < img.data.length; i += 4) img.data[i] = 255;
imgRef.current = img;
const gcanvas = document.createElement("canvas");
gcanvas.width = GW; gcanvas.height = GH;
const gctx = gcanvas.getContext("2d");
if (!gctx) return;
gctxRef.current = gctx;
const gimg = gctx.createImageData(GW, GH);
for (let j = 3; j < gimg.data.length; j += 4) gimg.data[j] = 255;
gimgRef.current = gimg;
randRef.current = makePRNG(0x3190406);
resetSim();
}
const prevRef = useRef(0) as Box<number>;
function frame(now: number): void {
const dt = Math.min(50, now - (prevRef.current || now));
prevRef.current = now;
sim(dt);
compose(now);
frRef.current++;
rafRef.current = requestAnimationFrame(frame);
}
function stillDraw(): void {
fit();
for (let i = 0; i < 400; i++) sim(33);
compose(0);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colRef.current = {
ink: tokRGB(root, "--flake-ink"),
dusk: tokRGB(root, "--flake-dusk"),
core: tokRGB(root, "--flake-core"),
halo: tokRGB(root, "--flake-halo"),
mint: tokRGB(root, "--flake-mint"),
sky: tokRGB(root, "--flake-sky"),
mist: tokRGB(root, "--flake-mist"),
};
buildLUT();
fit();
function onResize(): void {
if (reduced()) stillDraw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillDraw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, sps, rings]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><circle cx={128} cy={128} r={16} fill="none" stroke="currentColor" strokeWidth={16}/><circle cx={128} cy={128} r={48} fill="none" stroke="currentColor" strokeWidth={12} opacity={.75}/><circle cx={128} cy={128} r={80} fill="none" stroke="currentColor" strokeWidth={10} opacity={.5}/><circle cx={128} cy={128} r={108} fill="none" stroke="currentColor" strokeWidth={8} opacity={.3}/></svg>
<span>입자가 붙은 순간을 번호로 기록해 나이별로 색을 달리하면, 결정의 성장 연륜이 나무 나이테처럼 파스텔 동심원으로 읽힙니다</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 className={styles.val}>{ringsShown}</b> · 응집 <b className={styles.val}>{spsShown}</b>/초</span>
<span className={styles.metaDim}>캔버스 · ImageData · putImageData</span>
</div>
</div>
</main>
);
}06 · Lattice coloring — etching the age of each cell as pastel rings — Measured area 9.325% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
order.push is the only line that gives cells their age
w.gx = nx; w.gy = ny;
var i = idx(nx, ny);
if (occ[i]) return false;
var nc = (occ[idx(nx - 1, ny)] ? 1 : 0) + (occ[idx(nx + 1, ny)] ? 1 : 0) +
(occ[idx(nx, ny - 1)] ? 1 : 0) + (occ[idx(nx, ny + 1)] ? 1 : 0);
if (!nc) return false;
// branch probability — the fewer neighbors already touching, the more readily a cell sticks (DLA dendrite bias)
var p = nc === 1 ? 1.0 : nc === 2 ? 0.62 : nc === 3 ? 0.22 : 0.10;
if (rand() >= p) return false;
occ[i] = 1;
order.push(i);
age[i] = order.length; // age = the order it stuck in — the sole source of color
respawn(w);
return true;
07Pointer seeding — a second crystal competes at your click
Drop another seed wherever you click or tap and particles start competing between two crystals for the same walkers — the sticking rule never changes, a particle sticks to whichever crystal it touches, so arms from each crystal tangle together where they meet.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round"><path d="M128 22v212"/><path d="M38.9 73.5l178.2 129"/><path d="M217.1 73.5L38.9 202.5"/><path d="M104 46l24 20 24-20"/><path d="M104 210l24-20 24 20"/></g></svg>
<span>Plant an extra seed at every spot you click, in addition to the central crystal, and particles drift between the crystals, sticking wherever they compete — branches meet and intertwine in mutual growth</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Pointer Seed Competing-Crystal Growth Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdVal">1</b>crystals · click to add a seed</span>
<span class="cd__meta cd__meta--dim">pointermove · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터 씨앗(07). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(6각 격자 DLA 응집 + 광륜 blur + 잔광 꼬리)가 만든다.
// 무대는 밤하늘 잉크. 결정은 파스텔 화이트 코어에 결정마다 라일락·민트·핑크 고유 색.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음 — 전부 파스텔이거나 어스름 무채색) ──
$flake-ink: #141720; // 밤하늘 무대
$flake-sky: #232840; // 깊은 슬레이트
$flake-core: #f4f7ff; // 결정 코어 — 서리 화이트
$flake-lilac: #dbd6eb; // 결정 1 라일락
$flake-mint: #a8dbc8; // 결정 2 민트
$flake-pink: #f2b9cf; // 결정 3 핑크
$flake-node: #d5d9e4; // 안개색 글자·테두리
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-sky: #{$flake-sky};
--flake-core: #{$flake-core};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-pink: #{$flake-pink};
--flake-node: #{$flake-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-node; // 잉크 무대 위 — 안개색 글자(대비 10.9:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-lilac; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-node, .18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flake-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-node, .78); }
.cd__val { color: $flake-mint; font-weight: 700; } // 잉크 위 민트 라벨(대비 9.9:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 다 자란 한 장만 남는다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #141720;
--flake-sky: #232840;
--flake-core: #f4f7ff;
--flake-lilac: #dbd6eb;
--flake-mint: #a8dbc8;
--flake-pink: #f2b9cf;
--flake-node: #d5d9e4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d5d9e4;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #dbd6eb;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #141720;
box-shadow: inset 0 0 0 1px rgba(213, 217, 228, 0.18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #dbd6eb;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 217, 228, 0.78);
}
.cd__val {
color: #a8dbc8;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 포인터 씨앗(07). DLA(확산제한응집) — 바깥 링에서 태어난 입자가 6각 grid cells 위를 랜덤워크하다
// 결정에 닿는 순간 붙는다. 씨앗(결정 중심)을 여러 개 심으면 입자들이 어느 결정에 닿느냐를
// 두고 경쟁하며, 가지가 만나는 지점에서 서로 얽혀 붙는다(만나도 규칙은 그대로 — 닿는 쪽에 붙는 것뿐).
// 화면은 4겹으로 산다: ① 숨쉬는 조명·떠도는 워시 배경 ② 결정 광륜(grid cells 확대 blur + lighter)
// ③ grid cells ImageData 를 2배로 확대한 결정 본체 ④ 잔광 꼬리를 남기는 부유 입자 + 반짝임.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var STICKS_PER_FRAME = 58; // 한 프레임에 붙일 입자 수 — 늘리면 결정이 빨리 자란다
var RMAX = 34; // 결정 최대 반경(셀) — 이 안에서만 붙는다
var WALKER_MAX_STEPS = 420; // 입자 하나의 최대 걸음 — 넘으면 죽고 새 입자가 태어난다
var ATTEMPT_CAP = 420; // 프레임당 입자 시도 상한 — 결정이 다 차도 루프가 안 멈추게
var MAX_CRYSTALS = 5; // 씨앗 상한
// 포인터가 없는 자동 렌더(측정 gif/mp4) 폴백 — [시각ms, 가로비, 세로비]마다 씨앗을 자동으로 심는다
var AUTO_SEEDS = [[450, 0.72, 0.34], [1150, 0.26, 0.30]];
// ────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
// 내부 해상도는 424×180 로 고정 — CSS 로 축소하므로 창 크기가 바뀌어도 grid cells 상태가 안 흔들린다
var W = cv.width, H = cv.height;
var CELL = 2, GW = W / CELL, GH = H / CELL; // grid cells 212×90
// 시드 고정 PRNG — mulberry32. 결정의 둥근 결이 매 실행 같은 성격으로 흐른다
var prngState = 0x31907a >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 에서 getComputedStyle 로 — 파스텔 토큰이 scss 한 곳에
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_INK = tokRGB('--flake-ink');
var C_CORE = tokRGB('--flake-core');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_PINK = tokRGB('--flake-pink');
var TINTS = [C_LILAC, C_MINT, C_PINK]; // 결정마다 고유 파스텔 — 경쟁하는 둘을 눈으로 구분한다
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── grid cells 상태 ──
var tStick = new Int32Array(GW * GH); // 셀에 붙은 시각(ms), -1 = 빈칸
var owner = new Int8Array(GW * GH); // 셀을 소유한 결정 번호
for (var i0 = 0; i0 < tStick.length; i0++) { tStick[i0] = -1; owner[i0] = -1; }
// 6각 인접(odd-r 행 오프셋) — 정사각 픽셀 위에 60° 방향의 가지를 세우는 눈결정 위상
var NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
var NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
var crystals = []; // {cx, cy, maxR, born, count, id}
var sparkles = []; // {x, y, t} 갓 붙은 자리의 반짝임
var rings = []; // {x, y, t} 씨앗을 심은 순간의 확산 링
var motes = []; // 부유 입자 — 잔광 꼬리
function idx(x, y) { return y * GW + x; }
function inside(x, y) { return x >= 0 && x < GW && y >= 0 && y < GH; }
function hexNbr(y) { return (y & 1) ? NX_ODD : NX_EVEN; }
function hexNbrY(y) { return (y & 1) ? NY_ODD : NY_EVEN; }
function filledNb(x, y) {
var nx = hexNbr(y), ny = hexNbrY(y), n = 0;
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) n++;
}
return n;
}
function stick(x, y, own, now) {
var id = idx(x, y);
tStick[id] = now; owner[id] = own;
if (sparkles.length > 96) sparkles.shift();
sparkles.push({ x: x * CELL + 1, y: y * CELL + 1, t: now });
}
function plantSeed(fx, fy, now) {
if (crystals.length >= MAX_CRYSTALS) return;
var cx = Math.round(fx * GW), cy = Math.round(fy * GH);
if (!inside(cx, cy) || tStick[idx(cx, cy)] >= 0) return;
var id = crystals.length;
crystals.push({ cx: cx, cy: cy, maxR: 2, born: now, count: 0, id: id });
tStick[idx(cx, cy)] = now; owner[idx(cx, cy)] = id;
var nx = hexNbr(cy), ny = hexNbrY(cy); // 씨앗 주변 한 바퀴를 얼려 출발점을 만든다
for (var k = 0; k < 6; k++) {
var ax = cx + nx[k], ay = cy + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] < 0) { tStick[idx(ax, ay)] = now; owner[idx(ax, ay)] = id; }
}
rings.push({ x: cx * CELL + 1, y: cy * CELL + 1, t: now });
}
// ── DLA 응집 — 씨앗 링에서 입자를 뿌려 6각 랜덤워크, 닿으면 확률로 붙는다 ──
function aggregate(now) {
if (!crystals.length) return;
var stuck = 0, attempts = 0;
while (stuck < STICKS_PER_FRAME && attempts < ATTEMPT_CAP) {
attempts++;
var cr = crystals[(rand() * crystals.length) | 0];
var ang = rand() * Math.PI * 2;
var rr = cr.maxR + 6 + rand() * 14;
var x = Math.round(cr.cx + Math.cos(ang) * rr);
var y = Math.round(cr.cy + Math.sin(ang) * rr * 0.92);
var steps = 0;
while (steps < WALKER_MAX_STEPS) {
steps++;
var dir = (rand() * 6) | 0;
var nb = hexNbr(y), sb = hexNbrY(y);
x += nb[dir]; y += sb[dir];
if (!inside(x, y)) break;
if (tStick[idx(x, y)] >= 0) { x -= nb[dir]; y -= sb[dir]; continue; } // 언 셀엔 못 들어간다
var n = filledNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14; // 팁 바이어스 — 가지가 뻗는다
var dist = Math.hypot(x - cr.cx, y - cr.cy);
p *= 0.35 + 0.65 * Math.min(1, dist / (cr.maxR + 4)); // 바깥쪽에 더 잘 붙는다
if (dist < RMAX && rand() < p) {
var own = ownerOfNeighbor(x, y);
stick(x, y, own, now);
cr.count++; stuck++;
if (dist > cr.maxR) cr.maxR = dist;
break;
}
}
}
}
}
function ownerOfNeighbor(x, y) {
var nx = hexNbr(y), ny = hexNbrY(y);
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) return owner[idx(ax, ay)]; // 닿은 결정의 소유가 된다 — 경쟁
}
return 0;
}
// ── grid cells → ImageData → 2배 확대 캔버스 ──
var gridCv = document.createElement('canvas'); gridCv.width = GW; gridCv.height = GH;
var gtx = gridCv.getContext('2d');
var img = gtx.createImageData(GW, GH);
var px = img.data;
function repaintGrid(now) {
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var id = y * GW + x, o = id * 4;
var t0 = tStick[id];
if (t0 < 0) { px[o + 3] = 0; continue; }
var age = now - t0;
var tint = TINTS[owner[id] % TINTS.length];
var k = Math.min(1, age / 900); // 갓 붙은 하양 → 결정 고유 파스텔로 정착
var lift = filledNb(x, y) <= 2 ? 30 : 0; // 팁(인접 ≤2)은 서리 반짝임으로 더 밝다
px[o] = Math.min(255, C_CORE[0] + (tint[0] - C_CORE[0]) * k + lift);
px[o + 1] = Math.min(255, C_CORE[1] + (tint[1] - C_CORE[1]) * k + lift);
px[o + 2] = Math.min(255, C_CORE[2] + (tint[2] - C_CORE[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
// ── 배경 — 숨쉬는 중앙 조명 + 리사주로 떠도는 민트 워시 ──
var bgCv = document.createElement('canvas'); bgCv.width = W; bgCv.height = H;
var btx = bgCv.getContext('2d');
function repaintBg(now) {
btx.globalCompositeOperation = 'source-over';
btx.fillStyle = rgba(C_INK, 1);
btx.fillRect(0, 0, W, H);
var a1 = 0.13 + 0.09 * Math.sin(now / 700);
var g1 = btx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, 235);
g1.addColorStop(0, rgba(C_LILAC, a1));
g1.addColorStop(1, rgba(C_LILAC, 0));
btx.fillStyle = g1; btx.fillRect(0, 0, W, H);
var wx = W * (0.5 + 0.30 * Math.sin(now / 2600));
var wy = H * (0.5 + 0.26 * Math.sin(now / 1900 + 1.7));
var g2 = btx.createRadialGradient(wx, wy, 0, wx, wy, 170);
g2.addColorStop(0, rgba(C_MINT, 0.10));
g2.addColorStop(1, rgba(C_MINT, 0));
btx.fillStyle = g2; btx.fillRect(0, 0, W, H);
// 반대편에서 도는 핑크 안개 — 배경 세 겹이 각기 다른 주기로 숨쉰다
var px2 = W * (0.5 - 0.32 * Math.sin(now / 3100));
var py2 = H * (0.5 - 0.24 * Math.sin(now / 2200 + 0.8));
var g3 = btx.createRadialGradient(px2, py2, 0, px2, py2, 160);
g3.addColorStop(0, rgba(C_PINK, 0.085));
g3.addColorStop(1, rgba(C_PINK, 0));
btx.fillStyle = g3; btx.fillRect(0, 0, W, H);
}
// ── 잔광 꼬리 레이어 — 지우지 않고 destination-out 로 녹여 꼬리를 남긴다 ──
var trailCv = document.createElement('canvas'); trailCv.width = W; trailCv.height = H;
var ttx = trailCv.getContext('2d');
function initMotes() {
var cols = [C_LILAC, C_MINT, C_PINK, C_CORE];
for (var m = 0; m < 44; m++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: (rand() - 0.5) * 1.3, vy: (rand() - 0.5) * 0.85,
soft: rand() < 0.3, col: cols[(rand() * cols.length) | 0]
});
}
}
function repaintTrails() {
ttx.globalCompositeOperation = 'destination-out';
ttx.fillStyle = 'rgba(0,0,0,0.13)';
ttx.fillRect(0, 0, W, H);
ttx.globalCompositeOperation = 'lighter';
for (var i = 0; i < motes.length; i++) {
var mo = motes[i];
mo.x += mo.vx; mo.y += mo.vy;
if (mo.x < -8) mo.x = W + 8; else if (mo.x > W + 8) mo.x = -8;
if (mo.y < -8) mo.y = H + 8; else if (mo.y > H + 8) mo.y = -8;
if (mo.soft) {
var g = ttx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, 5);
g.addColorStop(0, rgba(mo.col, 0.20));
g.addColorStop(1, rgba(mo.col, 0));
ttx.fillStyle = g;
ttx.fillRect(mo.x - 5, mo.y - 5, 10, 10);
} else {
ttx.fillStyle = rgba(mo.col, 0.5);
ttx.fillRect(mo.x, mo.y, 1.6, 1.6);
}
}
}
// ── 포인터 — 움직이면 헤일로가 따라오고, 누르면 그 자리에 씨앗 ──
var ptr = { x: -99, y: -99, t: -9999, on: false };
function toLocal(e) {
var r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
}
field.addEventListener('pointermove', function (e) {
var p = toLocal(e);
ptr.x = p[0]; ptr.y = p[1]; ptr.t = perfNow(); ptr.on = true;
});
field.addEventListener('pointerleave', function () { ptr.on = false; });
field.addEventListener('pointerdown', function (e) {
var p = toLocal(e);
plantSeed(p[0] / W, p[1] / H, perfNow());
valOut.textContent = String(crystals.length);
});
var t0 = -1;
function perfNow() { return t0 < 0 ? 0 : performance.now() - t0; }
// ── 합성 ──
function draw(now) {
repaintBg(now);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1; ctx.filter = 'none';
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
// 결정 광륜 — 격자를 뿌옇게 확대해 lighter 로 쌓는다(호흡하는 할로)
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(5px)';
ctx.globalAlpha = 0.26 + 0.08 * Math.sin(now / 820);
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = 'none'; ctx.globalAlpha = 1;
// 결정 본체 — 픽셀 그대로 (셀 2px 각진 서리 결)
ctx.globalCompositeOperation = 'source-over';
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
// 잔광 꼬리
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(trailCv, 0, 0);
// 씨앗 확산 링
for (var i = rings.length - 1; i >= 0; i--) {
var r = rings[i], ra = now - r.t;
if (ra > 620) { rings.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(r.x, r.y, 5 + ra * 0.085, 0, Math.PI * 2);
ctx.strokeStyle = rgba(C_LILAC, 0.38 * (1 - ra / 620));
ctx.lineWidth = 1.6;
ctx.stroke();
}
// 갓 붙은 입자의 반짝임
for (var s = sparkles.length - 1; s >= 0; s--) {
var sp = sparkles[s], sa = now - sp.t;
if (sa > 340) { sparkles.splice(s, 1); continue; }
var fal = 1 - sa / 340;
ctx.fillStyle = rgba(C_CORE, 0.75 * fal);
ctx.fillRect(sp.x - 1.2, sp.y - 1.2, 2.4, 2.4);
}
// 포인터 헤일로 — 결정 위를 스치는 은은한 수증기
if (ptr.on && now - ptr.t < 420) {
var pg = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, 26);
pg.addColorStop(0, rgba(C_CORE, 0.16));
pg.addColorStop(1, rgba(C_CORE, 0));
ctx.fillStyle = pg;
ctx.fillRect(ptr.x - 26, ptr.y - 26, 52, 52);
}
ctx.globalCompositeOperation = 'source-over';
}
var lastVal = 0;
function frame(nowRaw) {
if (t0 < 0) t0 = nowRaw;
var now = nowRaw - t0;
// 자동 씨앗 폴백 — 포인터가 없는 측정 렌더에서도 결정이 여럿 경쟁하게 한다
for (var i = 0; i < AUTO_SEEDS.length; i++) {
if (AUTO_SEEDS[i][0] > 0 && now >= AUTO_SEEDS[i][0]) {
plantSeed(AUTO_SEEDS[i][1], AUTO_SEEDS[i][2], now);
AUTO_SEEDS[i][0] = 0;
}
}
aggregate(now);
repaintGrid(now);
repaintTrails();
draw(now);
if (now - lastVal > 480) { lastVal = now; valOut.textContent = String(crystals.length); }
requestAnimationFrame(frame);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
initMotes();
plantSeed(0.46, 0.50, 0);
if (reduce) {
// 결정이 다 자란 한 장만 남긴다 — 3초치 응집을 동기로 몰아 그린다
for (var w = 0; w < 90; w++) aggregate(w * 16.7);
repaintGrid(3000);
repaintBg(3000);
draw(3000);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// PointerSeedGrow — vanilla 판(run/319/demo/07-snowflake-pointer-seed-grow/index.html) 의 React 포트.
// 6각 격자 DLA(확산제한응집) — 바깥에서 태어난 입자가 랜덤워크하다 결정에 닿으면 붙는다.
// 클릭(포인터다운)한 자리에 씨앗을 더 심으면 입자들이 여러 결정 사이에서 경쟁하며 붙는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./PointerSeedGrow.module.scss";
export type PointerSeedGrowProps = {
duration?: string;
easing?: string;
color?: string;
sticksPerFrame?: number; // 한 프레임에 붙일 입자 수 — 늘리면 결정이 빨리 자란다
rMax?: number; // 결정 최대 반경(셀)
maxCrystals?: number; // 씨앗 상한
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Crystal = { cx: number; cy: number; maxR: number; count: number };
type Sparkle = { x: number; y: number; t: number };
type Ring = { x: number; y: number; t: number };
type Mote = { x: number; y: number; vx: number; vy: number; soft: boolean; col: RGB };
const FIELD_LABEL = "포인터 씨앗 결정 경쟁 성장 실험판";
const AUTO_SEEDS_INIT: [number, number, number][] = [[450, 0.72, 0.34], [1150, 0.26, 0.30]];
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
const NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
export default function PointerSeedGrow({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a8ef",
sticksPerFrame = 58,
rMax = 34,
maxCrystals = 5,
}: PointerSeedGrowProps) {
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<{ ink: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB } | null>(null);
const randRef = useRef(makePRNG(0x31907a)) as Box<() => number>;
const [valLabel, setValLabel] = useState(1);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// 격자 상태 — 캔버스 내부 해상도는 424x180 고정
const W = 424, H = 180, CELL = 2, GW = W / CELL, GH = H / CELL;
const tStickRef = useRef<Int32Array>(new Int32Array(GW * GH).fill(-1)) as Box<Int32Array>;
const ownerRef = useRef<Int8Array>(new Int8Array(GW * GH).fill(-1)) as Box<Int8Array>;
const crystalsRef = useRef<Crystal[]>([]) as Box<Crystal[]>;
const sparklesRef = useRef<Sparkle[]>([]) as Box<Sparkle[]>;
const ringsRef = useRef<Ring[]>([]) as Box<Ring[]>;
const motesRef = useRef<Mote[]>([]) as Box<Mote[]>;
const autoSeedsRef = useRef<[number, number, number][]>(AUTO_SEEDS_INIT.map((s) => [...s] as [number, number, number])) as Box<[number, number, number][]>;
const ptrRef = useRef({ x: -99, y: -99, t: -9999, on: false }) as Box<{ x: number; y: number; t: number; on: boolean }>;
const gridCvRef = useRef<HTMLCanvasElement | null>(null);
const bgCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const rafRef = useRef(0) as Box<number>;
function idx(x: number, y: number): number { return y * GW + x; }
function inside(x: number, y: number): boolean { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y: number): number[] { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y: number): number[] { return (y & 1) ? NY_ODD : NY_EVEN; }
function filledNb(x: number, y: number): number {
const tStick = tStickRef.current;
const nx = nbX(y), ny = nbY(y);
let n = 0;
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) n++;
}
return n;
}
function stick(x: number, y: number, own: number, now: number): void {
const id = idx(x, y);
tStickRef.current[id] = now;
ownerRef.current[id] = own;
const sp = sparklesRef.current;
if (sp.length > 96) sp.shift();
sp.push({ x: x * CELL + 1, y: y * CELL + 1, t: now });
}
function ownerOfNeighbor(x: number, y: number): number {
const tStick = tStickRef.current, owner = ownerRef.current;
const nx = nbX(y), ny = nbY(y);
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] >= 0) return owner[idx(ax, ay)];
}
return 0;
}
function plantSeed(fx: number, fy: number, now: number): void {
const crystals = crystalsRef.current;
if (crystals.length >= maxCrystals) return;
const cx = Math.round(fx * GW), cy = Math.round(fy * GH);
if (!inside(cx, cy) || tStickRef.current[idx(cx, cy)] >= 0) return;
const id = crystals.length;
crystals.push({ cx, cy, maxR: 2, count: 0 });
stick(cx, cy, id, now);
const nx = nbX(cy), ny = nbY(cy);
for (let k = 0; k < 6; k++) {
const ax = cx + nx[k], ay = cy + ny[k];
if (inside(ax, ay) && tStickRef.current[idx(ax, ay)] < 0) stick(ax, ay, id, now);
}
ringsRef.current.push({ x: cx * CELL + 1, y: cy * CELL + 1, t: now });
}
function aggregate(now: number): void {
const crystals = crystalsRef.current;
if (!crystals.length) return;
const rand = randRef.current, tStick = tStickRef.current;
let stuck = 0, attempts = 0;
const ATTEMPT_CAP = 420, WALKER_MAX_STEPS = 420;
while (stuck < sticksPerFrame && attempts < ATTEMPT_CAP) {
attempts++;
const cr = crystals[(rand() * crystals.length) | 0];
const ang = rand() * Math.PI * 2;
const rr = cr.maxR + 6 + rand() * 14;
let x = Math.round(cr.cx + Math.cos(ang) * rr);
let y = Math.round(cr.cy + Math.sin(ang) * rr * 0.92);
let steps = 0;
while (steps < WALKER_MAX_STEPS) {
steps++;
const dir = (rand() * 6) | 0;
const bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (tStick[idx(x, y)] >= 0) { x -= bx[dir]; y -= by[dir]; continue; }
const n = filledNb(x, y);
if (n > 0) {
let p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14;
const dist = Math.hypot(x - cr.cx, y - cr.cy);
p *= 0.35 + 0.65 * Math.min(1, dist / (cr.maxR + 4));
if (dist < rMax && rand() < p) {
const own = ownerOfNeighbor(x, y);
stick(x, y, own, now);
cr.count++; stuck++;
if (dist > cr.maxR) cr.maxR = dist;
break;
}
}
}
}
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function repaintGrid(now: number): void {
const gridCv = gridCvRef.current;
if (!gridCv) return;
const gtx = gridCv.getContext("2d");
if (!gtx) return;
const col = colRef.current;
if (!col) return;
const tStick = tStickRef.current, owner = ownerRef.current;
const TINTS: RGB[] = [col.lilac, col.mint, col.pink];
const img = gtx.createImageData(GW, GH);
const px = img.data;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const id = y * GW + x, o = id * 4;
const t0 = tStick[id];
if (t0 < 0) { px[o + 3] = 0; continue; }
const age = now - t0;
const tint = TINTS[((owner[id] % 3) + 3) % 3];
const k = Math.min(1, age / 900);
const lift = filledNb(x, y) <= 2 ? 30 : 0;
px[o] = Math.min(255, col.core[0] + (tint[0] - col.core[0]) * k + lift);
px[o + 1] = Math.min(255, col.core[1] + (tint[1] - col.core[1]) * k + lift);
px[o + 2] = Math.min(255, col.core[2] + (tint[2] - col.core[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
function repaintBg(now: number): void {
const bgCv = bgCvRef.current, col = colRef.current;
if (!bgCv || !col) return;
const btx = bgCv.getContext("2d");
if (!btx) return;
btx.globalCompositeOperation = "source-over";
btx.fillStyle = rgba(col.ink, 1);
btx.fillRect(0, 0, W, H);
const a1 = 0.09 + 0.07 * Math.sin(now / 700);
const g1 = btx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, 205);
g1.addColorStop(0, rgba(col.lilac, a1));
g1.addColorStop(1, rgba(col.lilac, 0));
btx.fillStyle = g1; btx.fillRect(0, 0, W, H);
const wx = W * (0.5 + 0.30 * Math.sin(now / 2600));
const wy = H * (0.5 + 0.26 * Math.sin(now / 1900 + 1.7));
const g2 = btx.createRadialGradient(wx, wy, 0, wx, wy, 150);
g2.addColorStop(0, rgba(col.mint, 0.07));
g2.addColorStop(1, rgba(col.mint, 0));
btx.fillStyle = g2; btx.fillRect(0, 0, W, H);
}
function initMotes(): void {
const col = colRef.current;
if (!col) return;
const rand = randRef.current;
const cols: RGB[] = [col.lilac, col.mint, col.pink, col.core];
const motes = motesRef.current;
for (let m = 0; m < 26; m++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: (rand() - 0.5) * 1.15, vy: (rand() - 0.5) * 0.75,
soft: rand() < 0.22, col: cols[(rand() * cols.length) | 0],
});
}
}
function repaintTrails(): void {
const trailCv = trailCvRef.current;
if (!trailCv) return;
const ttx = trailCv.getContext("2d");
if (!ttx) return;
ttx.globalCompositeOperation = "destination-out";
ttx.fillStyle = "rgba(0,0,0,0.13)";
ttx.fillRect(0, 0, W, H);
ttx.globalCompositeOperation = "lighter";
const motes = motesRef.current;
for (let i = 0; i < motes.length; i++) {
const mo = motes[i];
mo.x += mo.vx; mo.y += mo.vy;
if (mo.x < -8) mo.x = W + 8; else if (mo.x > W + 8) mo.x = -8;
if (mo.y < -8) mo.y = H + 8; else if (mo.y > H + 8) mo.y = -8;
if (mo.soft) {
const g = ttx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, 5);
g.addColorStop(0, rgba(mo.col, 0.20));
g.addColorStop(1, rgba(mo.col, 0));
ttx.fillStyle = g;
ttx.fillRect(mo.x - 5, mo.y - 5, 10, 10);
} else {
ttx.fillStyle = rgba(mo.col, 0.5);
ttx.fillRect(mo.x, mo.y, 1.6, 1.6);
}
}
}
function draw(now: number): void {
const ctx = ctxRef.current, col = colRef.current;
const gridCv = gridCvRef.current, bgCv = bgCvRef.current, trailCv = trailCvRef.current;
if (!ctx || !col || !gridCv || !bgCv || !trailCv) return;
repaintBg(now);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1; ctx.filter = "none";
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
ctx.globalCompositeOperation = "lighter";
ctx.filter = "blur(5px)";
ctx.globalAlpha = 0.26 + 0.08 * Math.sin(now / 820);
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = "none"; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(trailCv, 0, 0);
const rings = ringsRef.current;
for (let i = rings.length - 1; i >= 0; i--) {
const r = rings[i], ra = now - r.t;
if (ra > 620) { rings.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(r.x, r.y, 5 + ra * 0.085, 0, Math.PI * 2);
ctx.strokeStyle = rgba(col.lilac, 0.38 * (1 - ra / 620));
ctx.lineWidth = 1.6;
ctx.stroke();
}
const sparkles = sparklesRef.current;
for (let s = sparkles.length - 1; s >= 0; s--) {
const sp = sparkles[s], sa = now - sp.t;
if (sa > 340) { sparkles.splice(s, 1); continue; }
const fal = 1 - sa / 340;
ctx.fillStyle = rgba(col.core, 0.75 * fal);
ctx.fillRect(sp.x - 1.2, sp.y - 1.2, 2.4, 2.4);
}
const ptr = ptrRef.current;
if (ptr.on && now - ptr.t < 420) {
const pg = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, 26);
pg.addColorStop(0, rgba(col.core, 0.16));
pg.addColorStop(1, rgba(col.core, 0));
ctx.fillStyle = pg;
ctx.fillRect(ptr.x - 26, ptr.y - 26, 52, 52);
}
ctx.globalCompositeOperation = "source-over";
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>): void {
const cv = cvRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const ptr = ptrRef.current;
ptr.x = (e.clientX - r.left) * (W / r.width);
ptr.y = (e.clientY - r.top) * (H / r.height);
ptr.on = true;
}
function handlePointerLeave(): void { ptrRef.current.on = false; }
function handlePointerDown(e: PointerEvent<HTMLDivElement>): void {
const cv = cvRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const fx = ((e.clientX - r.left) * (W / r.width)) / W;
const fy = ((e.clientY - r.top) * (H / r.height)) / H;
plantSeed(fx, fy, performance.now());
setValLabel(crystalsRef.current.length);
}
useEffect(() => {
const root = rootRef.current, cv = cvRef.current;
if (!root || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
gridCvRef.current = document.createElement("canvas");
gridCvRef.current.width = GW; gridCvRef.current.height = GH;
bgCvRef.current = document.createElement("canvas");
bgCvRef.current.width = W; bgCvRef.current.height = H;
trailCvRef.current = document.createElement("canvas");
trailCvRef.current.width = W; trailCvRef.current.height = H;
function tokRGB(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)];
}
colRef.current = {
ink: tokRGB("--flake-ink"), core: tokRGB("--flake-core"),
lilac: tokRGB("--flake-lilac"), mint: tokRGB("--flake-mint"), pink: tokRGB("--flake-pink"),
};
randRef.current = makePRNG(0x31907a);
tStickRef.current = new Int32Array(GW * GH).fill(-1);
ownerRef.current = new Int8Array(GW * GH).fill(-1);
crystalsRef.current = [];
sparklesRef.current = [];
ringsRef.current = [];
motesRef.current = [];
autoSeedsRef.current = AUTO_SEEDS_INIT.map((s) => [...s] as [number, number, number]);
initMotes();
plantSeed(0.46, 0.50, 0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let t0 = -1, lastVal = 0;
function frame(nowRaw: number): void {
if (t0 < 0) t0 = nowRaw;
const now = nowRaw - t0;
const seeds = autoSeedsRef.current;
for (let i = 0; i < seeds.length; i++) {
if (seeds[i][0] > 0 && now >= seeds[i][0]) {
plantSeed(seeds[i][1], seeds[i][2], now);
seeds[i][0] = 0;
}
}
aggregate(now);
repaintGrid(now);
repaintTrails();
draw(now);
if (now - lastVal > 480) { lastVal = now; setValLabel(crystalsRef.current.length); }
rafRef.current = requestAnimationFrame(frame);
}
if (reduce) {
for (let w = 0; w < 90; w++) aggregate(w * 16.7);
repaintGrid(3000);
repaintBg(3000);
draw(3000);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sticksPerFrame, rMax, maxCrystals]);
const rootCls = still ? `${styles.cd} ${styles.isStill}` : styles.cd;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round"><path d="M128 22v212" /><path d="M38.9 73.5l178.2 129" /><path d="M217.1 73.5L38.9 202.5" /><path d="M104 46l24 20 24-20" /><path d="M104 210l24-20 24 20" /></g></svg>
<span>중앙 결정에 더해 클릭한 자리마다 씨앗을 하나 더 심으면 입자들이 결정들 사이를 오가며 경쟁으로 붙습니다 — 가지가 만나 얽히는 상호 성장입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
>
<canvas className={styles.cv} ref={cvRef} width={W} height={H}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{valLabel}</b>개 결정 · 클릭으로 씨앗 추가</span>
<span className={styles.metaDim}>pointermove · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · Pointer seeding — a second crystal grows wherever you click — Measured area 34.032% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
plantSeed never changes the rule, only where it starts
function plantSeed(fx, fy, now) {
if (crystals.length >= MAX_CRYSTALS) return;
var cx = Math.round(fx * GW), cy = Math.round(fy * GH);
if (!inside(cx, cy) || tStick[idx(cx, cy)] >= 0) return;
var id = crystals.length;
crystals.push({ cx: cx, cy: cy, maxR: 2, born: now, count: 0, id: id });
tStick[idx(cx, cy)] = now; owner[idx(cx, cy)] = id;
var nx = hexNbr(cy), ny = hexNbrY(cy); // freeze one ring around the seed to give it a starting edge
for (var k = 0; k < 6; k++) {
var ax = cx + nx[k], ay = cy + ny[k];
if (inside(ax, ay) && tStick[idx(ax, ay)] < 0) { tStick[idx(ax, ay)] = now; owner[idx(ax, ay)] = id; }
}
rings.push({ x: cx * CELL + 1, y: cy * CELL + 1, t: now });
}
08Melt and refreeze — peeling the rim, keeping the core
Run a temperature cycle over a fully grown crystal: on the warm half, probabilistically peel exposed surface cells into drifting particles; on the cold half, those particles re-stick. The older core survives at a lower melt odds while only the rim keeps regrowing.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round"><path d="M128 26v204"/><path d="M43 74l170 128"/><path d="M213 74L43 202"/><circle cx="128" cy="128" r="18"/></g></svg>
<span>Detach surface particles by chance and drop them nearby again — the old core stays put while the edge keeps regrowing, a breathing crystal's cycle</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Melt-Recrystallize Cycle Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">cycle <b class="cd__val" id="cdVal">4.0</b>s · melt → re-aggregate</span>
<span class="cd__meta cd__meta--dim">ImageData · putImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 녹았다 얼기 반복(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(표면 박리 → 방류 입자 랜덤워크 → 재응집 + 온도 순환 조명)가 만든다.
// 무대는 밤하늘 잉크. 결정은 나이 밴드(연륜)를 파스텔 화이트→라일락→민트→핑크로 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음 — 전부 파스텔이거나 어스름 무채색) ──
$flake-ink: #12161f; // 밤하늘 무대
$flake-sky: #20263a; // 깊은 슬레이트
$flake-core: #f3f6fb; // 결정 코어 — 서리 화이트
$flake-lilac: #dad6eb; // 찬 기류 라일락(연륜 1)
$flake-mint: #a5d9c6; // 재응집 민트(연륜 2)
$flake-pink: #f0bbd2; // 더운 기류 핑크(연륜 3)
$flake-node: #d3d7e2; // 안개색 글자·테두리
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-sky: #{$flake-sky};
--flake-core: #{$flake-core};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-pink: #{$flake-pink};
--flake-node: #{$flake-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-node; // 잉크 무대 위 — 안개색 글자(대비 11.2:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-mint; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-node, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-mint; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-node, .78); }
.cd__val { color: $flake-lilac; font-weight: 700; } // 잉크 위 라일락 라벨(대비 7.6:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 다 자란 한 장만 남는다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #12161f;
--flake-sky: #20263a;
--flake-core: #f3f6fb;
--flake-lilac: #dad6eb;
--flake-mint: #a5d9c6;
--flake-pink: #f0bbd2;
--flake-node: #d3d7e2;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d3d7e2;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #a5d9c6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12161f;
box-shadow: inset 0 0 0 1px rgba(211, 215, 226, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #a5d9c6;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(211, 215, 226, 0.78);
}
.cd__val {
color: #dad6eb;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 녹았다 얼기 반복(08). DLA 로 완전히 자란 결정을 무대에 올리고, 온도 사이클을 돌린다.
// 주기의 뒷반(더운 기류)에는 결정 표면(노출된 셀)을 확률적으로 떼어내 근처에 랜덤워크 입자로
// 풀어놓고, 앞반(찬 기류)에는 그 입자들이 표면에 다시 붙는다. 코어(오래된 셀)는 녹을 확률이
// 낮아 남고, 가장자리만 계속 새로 자란다 — 나무 나이테 같은 온도 연륜이 남는다.
// 화면은 5겹으로 산다: ① 온도에 따라 색온도가 도는 배경 조명 ② grid cells 확대 blur 광륜
// ③ grid cells ImageData 본체(나이 색) ④ 녹아 떨어져 떠다니는 방류 입자 ⑤ 표면 반짝임.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var CYCLE_MS = 2000; // 온도 순환 한 바퀴(측정 gif 2초의 약수 — 주기가 정확히 겹친다)
var MELT_FRAC = 0.5; // cycle 뒷반(녹는 구간) 비율
var MELT_RATE = 4.6; // 프레임당 표면 박리 시도 수(온도 스케일) — 늘리면 더 많이 녹는다
var FREEZE_PER_FR = 60; // 프레임당 재응집 입자 수
var RMAX = 38; // 결정 반경(셀)
// ────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
var W = cv.width, H = cv.height; // 내부 해상도 고정 424×180
var CELL = 2, GW = W / CELL, GH = H / CELL; // grid cells 212×90
var CX = (GW / 2) | 0, CY = ((GH / 2) | 0) - 6;
// 시드 고정 PRNG — mulberry32. 순환의 결이 매 실행 같은 성격으로 흐른다
var prngState = 0x31908c >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 에서 getComputedStyle 로
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_INK = tokRGB('--flake-ink');
var C_CORE = tokRGB('--flake-core');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_PINK = tokRGB('--flake-pink');
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── grid cells 상태 ──
var tStick = new Float32Array(GW * GH); // 셀에 붙은 시각(ms) — 나이·연륜의 원료
var frozen = new Uint8Array(GW * GH); // 1 = 언 셀
tStick.fill(-1);
// 6각 인접(odd-r)
var NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
var NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
function idx(x, y) { return y * GW + x; }
function inside(x, y) { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y) { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y) { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x, y) {
var nx = nbX(y), ny = nbY(y), n = 0;
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x, y, now) { frozen[idx(x, y)] = 1; tStick[idx(x, y)] = now; }
// ── 초기 성장 — DLA 로 이글 하나를 완전히 자려둔다(첫 프레임 전에 끝난다) ──
function growFull() {
stick(CX, CY, -9900);
var nx = nbX(CY), ny = nbY(CY);
for (var k = 0; k < 6; k++) {
var ax = CX + nx[k], ay = CY + ny[k];
if (inside(ax, ay)) stick(ax, ay, -9800);
}
var maxR = 2, want = 2050;
var got = 7, tries = 0;
while (got < want && tries < want * 260) {
tries++;
var ang = rand() * Math.PI * 2;
var rr = maxR + 6 + rand() * 16;
var x = Math.round(CX + Math.cos(ang) * rr);
var y = Math.round(CY + Math.sin(ang) * rr * 0.94);
var steps = 0;
while (steps < 480) {
steps++;
var dir = (rand() * 6) | 0;
var bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
var n = frozenNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14;
var dist = Math.hypot(x - CX, y - CY);
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < RMAX && rand() < p) {
stick(x, y, -9900 + got * 3); // 나이를 골고루 — growth rings 여러 겹
got++;
if (dist > maxR) maxR = dist;
break;
}
}
}
}
return got;
}
// ── 녹기 — 표면(노출 ≥3) 셀을 온도 확률로 떼어 방류 입자로 바꾼다 ──
var drifters = []; // {x, y, vx, vy, born, hot} 얼었다 녹아 떠도는 입자
function meltStep(heat, now) {
var tries = Math.round(MELT_RATE * heat * 10);
for (var i = 0; i < tries; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 6) continue; // 완전 매립 셀은 안 녹는다
var age = now - tStick[id];
var pCore = Math.max(0.02, 1 - age / 5200); // 오래된 코어는 녹을 확률이 낮다
var p = (n / 6) * heat * pCore * 0.5;
if (rand() < p) {
frozen[id] = 0;
var ang = rand() * Math.PI * 2, sp = 0.25 + rand() * 0.8;
drifters.push({
x: x * CELL + 1, y: y * CELL + 1,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
born: now, hot: true
});
}
}
}
// ── 재응집 — 방류 입자를 결정 표면에 다시 붙인다 ──
function refreezeStep(now) {
var stuck = 0, i = 0;
while (i < drifters.length && stuck < FREEZE_PER_FR) {
var d = drifters[i];
// 방류 입자의 표면 스캔 — grid cells 좌표로 환산해 인접 언 셀을 찾는다
var gx = (d.x / CELL) | 0, gy = (d.y / CELL) | 0;
var landed = false;
if (inside(gx, gy) && !frozen[idx(gx, gy)]) {
var n = frozenNb(gx, gy);
if (n > 0) {
var p = n === 1 ? 0.9 : n === 2 ? 0.5 : 0.12;
if (rand() < p) { stick(gx, gy, now); landed = true; stuck++; }
}
}
if (landed) drifters.splice(i, 1); else i++;
}
}
// 방류 입자 물리 — 찬 기류엔 결정 쪽으로 끌려간다(응집의 시각적 예고)
function stepDrifters(now, heat) {
for (var i = drifters.length - 1; i >= 0; i--) {
var d = drifters[i];
d.hot = now - d.born < 900;
var toC = [CX * CELL - d.x, CY * CELL - d.y];
var len = Math.hypot(toC[0], toC[1]) || 1;
var pull = (1 - heat) * 0.028;
d.vx += (toC[0] / len) * pull + (rand() - 0.5) * 0.05;
d.vy += (toC[1] / len) * pull + (rand() - 0.5) * 0.05;
var v = Math.hypot(d.vx, d.vy);
if (v > 1.4) { d.vx *= 1.4 / v; d.vy *= 1.4 / v; }
d.x += d.vx; d.y += d.vy;
if (d.x < 1 || d.x > W - 1 || d.y < 1 || d.y > H - 1) { drifters.splice(i, 1); }
}
}
// ── grid cells → ImageData — 나이(붙은 시각)를 파스텔 연륜으로 새긴다 ──
var gridCv = document.createElement('canvas'); gridCv.width = GW; gridCv.height = GH;
var gtx = gridCv.getContext('2d');
var img = gtx.createImageData(GW, GH);
var px = img.data;
var RINGS = [C_CORE, C_LILAC, C_MINT, C_PINK, C_CORE, C_LILAC]; // 나이 밴드 순환
function repaintGrid(now) {
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var id = y * GW + x, o = id * 4;
if (!frozen[id]) { px[o + 3] = 0; continue; }
var age = now - tStick[id];
var band = ((age / 1050) | 0) % RINGS.length; // 나이테 — 약 1초당 한 색
var tint = RINGS[band];
var k = Math.min(1, age / 500) * 0.8;
var lift = frozenNb(x, y) <= 2 ? 26 : 0;
px[o] = Math.min(255, C_CORE[0] + (tint[0] - C_CORE[0]) * k + lift);
px[o + 1] = Math.min(255, C_CORE[1] + (tint[1] - C_CORE[1]) * k + lift);
px[o + 2] = Math.min(255, C_CORE[2] + (tint[2] - C_CORE[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
// ── 배경 — 온도 색온도가 도는 조명. 찬 기류 = 라일락, 더운 기류 = 핑크 워시 ──
var bgCv = document.createElement('canvas'); bgCv.width = W; bgCv.height = H;
var btx = bgCv.getContext('2d');
function repaintBg(now, heat) {
btx.globalCompositeOperation = 'source-over';
btx.fillStyle = rgba(C_INK, 1);
btx.fillRect(0, 0, W, H);
var warm = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 240);
warm.addColorStop(0, rgba(C_LILAC, 0.13 + 0.07 * Math.sin(now / 830)));
warm.addColorStop(1, rgba(C_LILAC, 0));
btx.fillStyle = warm; btx.fillRect(0, 0, W, H);
var wx = W * (0.5 + 0.32 * Math.sin(now / 2300));
var wy = H * (0.28 + 0.16 * Math.sin(now / 1700 + 2.1));
var wg = btx.createRadialGradient(wx, wy, 0, wx, wy, 190);
wg.addColorStop(0, rgba(C_PINK, 0.04 + 0.095 * heat)); // 더워질수록 핑크 워시가 깊어진다
wg.addColorStop(1, rgba(C_PINK, 0));
btx.fillStyle = wg; btx.fillRect(0, 0, W, H);
var cg = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 150);
cg.addColorStop(0, rgba(C_MINT, 0.08 + 0.07 * (1 - heat)));
cg.addColorStop(1, rgba(C_MINT, 0));
btx.fillStyle = cg; btx.fillRect(0, 0, W, H);
// 반대편 코너를 도는 두 번째 안개 — 네 겹이 각자 다른 주기로 숨쉰다
var ax2 = W * (0.5 - 0.34 * Math.sin(now / 2800));
var ay2 = H * (0.85 + 0.12 * Math.sin(now / 2000 + 1.1));
var ag = btx.createRadialGradient(ax2, ay2, 0, ax2, ay2, 175);
ag.addColorStop(0, rgba(C_MINT, 0.06 + 0.04 * Math.sin(now / 1450 + 3.1)));
ag.addColorStop(1, rgba(C_MINT, 0));
btx.fillStyle = ag; btx.fillRect(0, 0, W, H);
}
// ── 합성 ──
function draw(now, heat) {
repaintBg(now, heat);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1; ctx.filter = 'none';
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(5px)';
ctx.globalAlpha = 0.24 + 0.09 * Math.sin(now / 900) + 0.05 * heat;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = 'none'; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
// 방류 입자 — 뜨거울 땐 핑크로 반짝, 식으면 코어 화이트로 접어든다
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < drifters.length; i++) {
var d = drifters[i];
var col = d.hot ? C_PINK : C_CORE;
var r = d.hot ? 1.9 : 1.4;
ctx.fillStyle = rgba(col, d.hot ? 0.8 : 0.55);
ctx.beginPath();
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
ctx.fill();
}
// 표면 반짝임 — 최근에 붙은 셀 근처에서 미세 광점이 흐른다
var n = 14;
for (var s = 0; s < n; s++) {
var a2 = (s / n) * Math.PI * 2 + now / 1400;
var rr2 = RMAX * CELL * (0.86 + 0.09 * Math.sin(now / 620 + s * 1.3));
var sx = CX * CELL + Math.cos(a2) * rr2;
var sy = CY * CELL + Math.sin(a2) * rr2 * 0.94;
var tw = 0.5 + 0.5 * Math.sin(now / 210 + s * 2.2);
ctx.fillStyle = rgba(C_MINT, 0.10 + 0.22 * tw * (1 - heat));
ctx.fillRect(sx - 1, sy - 1, 2, 2);
}
ctx.globalCompositeOperation = 'source-over';
}
function heatOf(now) {
// cycle 뒷반 = 더운(녹는) 구간 — 사인으로 부드럽게 오르내린다
var ph = (now % CYCLE_MS) / CYCLE_MS;
return ph < (1 - MELT_FRAC) ? 0 : Math.sin((ph - (1 - MELT_FRAC)) / MELT_FRAC * Math.PI);
}
var t0 = -1;
function frame(nowRaw) {
if (t0 < 0) t0 = nowRaw;
var now = nowRaw - t0;
var heat = heatOf(now);
if (heat > 0) meltStep(heat, now);
refreezeStep(now);
stepDrifters(now, heat);
repaintGrid(now);
draw(now, heat);
requestAnimationFrame(frame);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var total = growFull();
valOut.textContent = (CYCLE_MS / 1000).toFixed(1);
if (reduce) {
repaintGrid(0); repaintBg(0, 0); draw(0, 0);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// MeltRefreezeCycle — vanilla 판(run/319/demo/08-snowflake-melt-refreeze-cycle/index.html) 의 React 포트.
// DLA 로 완전히 자란 결정에 온도 순환을 돌린다: 주기 뒷반(더운 기류)엔 표면 셀을 확률적으로
// 떼어내 방류 입자로 풀어놓고, 앞반(찬 기류)엔 그 입자들이 표면에 다시 붙는다.
// 오래된 코어는 녹을 확률이 낮아 남고 가장자리만 계속 다시 자란다 — 숨쉬는 결정.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MeltRefreezeCycle.module.scss";
export type MeltRefreezeCycleProps = {
duration?: string;
easing?: string;
color?: string;
cycleMs?: number; // 온도 순환 한 바퀴
meltRate?: number; // 프레임당 표면 박리 시도 수(온도 스케일)
rMax?: number; // 결정 반경(셀)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Drifter = { x: number; y: number; vx: number; vy: number; born: number; hot: boolean };
type Colors = { ink: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB };
const FIELD_LABEL = "녹기·재결정 순환 결정 실험판";
const MELT_FRAC = 0.5;
const FREEZE_PER_FR = 60;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
const NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
export default function MeltRefreezeCycle({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b4a6ec",
cycleMs = 2000,
meltRate = 3.4,
rMax = 38,
}: MeltRefreezeCycleProps) {
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<Colors | null>(null);
const randRef = useRef(makePRNG(0x31908c)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const W = 424, H = 180, CELL = 2, GW = W / CELL, GH = H / CELL;
const CX = Math.floor(GW / 2), CY = Math.floor(GH / 2) - 6;
const frozenRef = useRef<Uint8Array>(new Uint8Array(GW * GH)) as Box<Uint8Array>;
const tStickRef = useRef<Float32Array>(new Float32Array(GW * GH)) as Box<Float32Array>;
const driftersRef = useRef<Drifter[]>([]) as Box<Drifter[]>;
const maxRRef = useRef(2) as Box<number>;
const gridCvRef = useRef<HTMLCanvasElement | null>(null);
const bgCvRef = useRef<HTMLCanvasElement | null>(null);
function idx(x: number, y: number): number { return y * GW + x; }
function inside(x: number, y: number): boolean { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y: number): number[] { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y: number): number[] { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x: number, y: number): number {
const frozen = frozenRef.current;
const nx = nbX(y), ny = nbY(y);
let n = 0;
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x: number, y: number, now: number): void {
frozenRef.current[idx(x, y)] = 1;
tStickRef.current[idx(x, y)] = now;
}
function growFull(): void {
const rand = randRef.current;
stick(CX, CY, -9900);
const nx = nbX(CY), ny = nbY(CY);
for (let k = 0; k < 6; k++) {
const ax = CX + nx[k], ay = CY + ny[k];
if (inside(ax, ay)) stick(ax, ay, -9800);
}
let maxR = 2, got = 7, tries = 0;
const want = 2050;
while (got < want && tries < want * 260) {
tries++;
const ang = rand() * Math.PI * 2;
const rr = maxR + 6 + rand() * 16;
let x = Math.round(CX + Math.cos(ang) * rr);
let y = Math.round(CY + Math.sin(ang) * rr * 0.94);
let steps = 0;
while (steps < 480) {
steps++;
const dir = (rand() * 6) | 0;
const bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozenRef.current[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
const n = frozenNb(x, y);
if (n > 0) {
let p = n === 1 ? 1 : n === 2 ? 0.55 : 0.14;
const dist = Math.hypot(x - CX, y - CY);
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < rMax && rand() < p) {
stick(x, y, -9900 + got * 3);
got++;
if (dist > maxR) maxR = dist;
break;
}
}
}
}
maxRRef.current = maxR;
}
function meltStep(heat: number, now: number): void {
const rand = randRef.current, frozen = frozenRef.current, tStick = tStickRef.current;
const tries = Math.round(meltRate * heat * 10);
for (let i = 0; i < tries; i++) {
const x = (rand() * GW) | 0, y = (rand() * GH) | 0;
const id = idx(x, y);
if (!frozen[id]) continue;
const n = frozenNb(x, y);
if (n >= 6) continue;
const age = now - tStick[id];
const pCore = Math.max(0.02, 1 - age / 5200);
const p = (n / 6) * heat * pCore * 0.5;
if (rand() < p) {
frozen[id] = 0;
const ang = rand() * Math.PI * 2, sp = 0.25 + rand() * 0.8;
driftersRef.current.push({
x: x * CELL + 1, y: y * CELL + 1,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
born: now, hot: true,
});
}
}
}
function refreezeStep(now: number): void {
const rand = randRef.current;
const drifters = driftersRef.current;
let stuck = 0, i = 0;
while (i < drifters.length && stuck < FREEZE_PER_FR) {
const d = drifters[i];
const gx = (d.x / CELL) | 0, gy = (d.y / CELL) | 0;
let landed = false;
if (inside(gx, gy) && !frozenRef.current[idx(gx, gy)]) {
const n = frozenNb(gx, gy);
if (n > 0) {
const p = n === 1 ? 0.9 : n === 2 ? 0.5 : 0.12;
if (rand() < p) { stick(gx, gy, now); landed = true; stuck++; }
}
}
if (landed) drifters.splice(i, 1); else i++;
}
}
function stepDrifters(now: number, heat: number): void {
const rand = randRef.current;
const drifters = driftersRef.current;
for (let i = drifters.length - 1; i >= 0; i--) {
const d = drifters[i];
d.hot = now - d.born < 900;
const toCx = CX * CELL - d.x, toCy = CY * CELL - d.y;
const len = Math.hypot(toCx, toCy) || 1;
const pull = (1 - heat) * 0.028;
d.vx += (toCx / len) * pull + (rand() - 0.5) * 0.05;
d.vy += (toCy / len) * pull + (rand() - 0.5) * 0.05;
const v = Math.hypot(d.vx, d.vy);
if (v > 1.4) { d.vx *= 1.4 / v; d.vy *= 1.4 / v; }
d.x += d.vx; d.y += d.vy;
if (d.x < 1 || d.x > W - 1 || d.y < 1 || d.y > H - 1) drifters.splice(i, 1);
}
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function repaintGrid(now: number): void {
const gridCv = gridCvRef.current, col = colRef.current;
if (!gridCv || !col) return;
const gtx = gridCv.getContext("2d");
if (!gtx) return;
const frozen = frozenRef.current, tStick = tStickRef.current;
const RINGS: RGB[] = [col.core, col.lilac, col.mint, col.pink, col.core, col.lilac];
const img = gtx.createImageData(GW, GH);
const px = img.data;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const id = y * GW + x, o = id * 4;
if (!frozen[id]) { px[o + 3] = 0; continue; }
const age = now - tStick[id];
const band = ((age / 1050) | 0) % RINGS.length;
const tint = RINGS[band];
const k = Math.min(1, age / 500) * 0.8;
const lift = frozenNb(x, y) <= 2 ? 26 : 0;
px[o] = Math.min(255, col.core[0] + (tint[0] - col.core[0]) * k + lift);
px[o + 1] = Math.min(255, col.core[1] + (tint[1] - col.core[1]) * k + lift);
px[o + 2] = Math.min(255, col.core[2] + (tint[2] - col.core[2]) * k + lift);
px[o + 3] = 255;
}
}
gtx.putImageData(img, 0, 0);
}
function repaintBg(now: number, heat: number): void {
const bgCv = bgCvRef.current, col = colRef.current;
if (!bgCv || !col) return;
const btx = bgCv.getContext("2d");
if (!btx) return;
btx.globalCompositeOperation = "source-over";
btx.fillStyle = rgba(col.ink, 1);
btx.fillRect(0, 0, W, H);
const warm = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 215);
warm.addColorStop(0, rgba(col.lilac, 0.085 + 0.05 * Math.sin(now / 830)));
warm.addColorStop(1, rgba(col.lilac, 0));
btx.fillStyle = warm; btx.fillRect(0, 0, W, H);
const wx = W * (0.5 + 0.32 * Math.sin(now / 2300));
const wy = H * (0.28 + 0.16 * Math.sin(now / 1700 + 2.1));
const wg = btx.createRadialGradient(wx, wy, 0, wx, wy, 165);
wg.addColorStop(0, rgba(col.pink, 0.025 + 0.065 * heat));
wg.addColorStop(1, rgba(col.pink, 0));
btx.fillStyle = wg; btx.fillRect(0, 0, W, H);
const cg = btx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 130);
cg.addColorStop(0, rgba(col.mint, 0.05 + 0.05 * (1 - heat)));
cg.addColorStop(1, rgba(col.mint, 0));
btx.fillStyle = cg; btx.fillRect(0, 0, W, H);
}
function draw(now: number, heat: number): void {
const ctx = ctxRef.current, col = colRef.current;
const gridCv = gridCvRef.current, bgCv = bgCvRef.current;
if (!ctx || !col || !gridCv || !bgCv) return;
repaintBg(now, heat);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1; ctx.filter = "none";
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bgCv, 0, 0);
ctx.globalCompositeOperation = "lighter";
ctx.filter = "blur(5px)";
ctx.globalAlpha = 0.24 + 0.09 * Math.sin(now / 900) + 0.05 * heat;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.filter = "none"; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(gridCv, 0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
const drifters = driftersRef.current;
for (let i = 0; i < drifters.length; i++) {
const d = drifters[i];
const dcol = d.hot ? col.pink : col.core;
const r = d.hot ? 1.9 : 1.4;
ctx.fillStyle = rgba(dcol, d.hot ? 0.8 : 0.55);
ctx.beginPath();
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
ctx.fill();
}
const n = 14, maxR = maxRRef.current;
for (let s = 0; s < n; s++) {
const a2 = (s / n) * Math.PI * 2 + now / 1400;
const rr2 = maxR * CELL * (0.86 + 0.09 * Math.sin(now / 620 + s * 1.3));
const sx = CX * CELL + Math.cos(a2) * rr2;
const sy = CY * CELL + Math.sin(a2) * rr2 * 0.94;
const tw = 0.5 + 0.5 * Math.sin(now / 210 + s * 2.2);
ctx.fillStyle = rgba(col.mint, 0.10 + 0.22 * tw * (1 - heat));
ctx.fillRect(sx - 1, sy - 1, 2, 2);
}
ctx.globalCompositeOperation = "source-over";
}
function heatOf(now: number): number {
const ph = (now % cycleMs) / cycleMs;
return ph < (1 - MELT_FRAC) ? 0 : Math.sin((ph - (1 - MELT_FRAC)) / MELT_FRAC * Math.PI);
}
useEffect(() => {
const root = rootRef.current, cv = cvRef.current;
if (!root || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
gridCvRef.current = document.createElement("canvas");
gridCvRef.current.width = GW; gridCvRef.current.height = GH;
bgCvRef.current = document.createElement("canvas");
bgCvRef.current.width = W; bgCvRef.current.height = H;
function tokRGB(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)];
}
colRef.current = {
ink: tokRGB("--flake-ink"), core: tokRGB("--flake-core"),
lilac: tokRGB("--flake-lilac"), mint: tokRGB("--flake-mint"), pink: tokRGB("--flake-pink"),
};
randRef.current = makePRNG(0x31908c);
frozenRef.current = new Uint8Array(GW * GH);
tStickRef.current = new Float32Array(GW * GH).fill(-1);
driftersRef.current = [];
growFull();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let t0 = -1;
function frame(nowRaw: number): void {
if (t0 < 0) t0 = nowRaw;
const now = nowRaw - t0;
const heat = heatOf(now);
if (heat > 0) meltStep(heat, now);
refreezeStep(now);
stepDrifters(now, heat);
repaintGrid(now);
draw(now, heat);
rafRef.current = requestAnimationFrame(frame);
}
if (reduce) {
repaintGrid(0); repaintBg(0, 0); draw(0, 0);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cycleMs, meltRate, rMax]);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
const cycleLabel = (cycleMs / 1000).toFixed(1);
return (
<main className={styles.stage}>
<div className={styles.cd} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round"><path d="M128 26v204" /><path d="M43 74l170 128" /><path d="M213 74L43 202" /><circle cx={128} cy={128} r={18} /></g></svg>
<span>결정 표면의 입자를 확률적으로 떼어내 주변에 다시 풀어놓으면 오래된 코어는 남고 가장자리만 계속 다시 자랍니다 — 숨을 쉬는 결정의 온도 순환입니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>주기 <b className={styles.val}>{cycleLabel}</b>s · 표면 녹이기 → 재응집</span>
<span className={styles.metaDim}>ImageData · putImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · Melt-and-refreeze — peeling the outermost particles and re-sticking them — Measured area 37.253% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
pCore protects the old center while the rim keeps cycling
// ── Melting — surface cells (exposure ≥ 3) peel off as drifting particles at a temperature-driven odds ──
var drifters = []; // {x, y, vx, vy, born, hot} particles that thawed and are drifting
function meltStep(heat, now) {
var tries = Math.round(MELT_RATE * heat * 10);
for (var i = 0; i < tries; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 6) continue; // fully buried cells never melt
var age = now - tStick[id];
var pCore = Math.max(0.02, 1 - age / 5200); // an old core melts at much lower odds
var p = (n / 6) * heat * pCore * 0.5;
if (rand() < p) {
frozen[id] = 0;
var ang = rand() * Math.PI * 2, sp = 0.25 + rand() * 0.8;
drifters.push({
09Macro-photo scene — one flake staged like microscopy
Light a dark stage with a central radial gradient, grow one crystal past half the frame on a timelapse, and keep the surface slowly reshuffling once growth completes so a depth-of-field macro shot never quite sits still.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14" stroke-linecap="round"><path d="M128 26v204"/><path d="M43 74l170 128"/><path d="M213 74L43 202"/><path d="M106 40l22 18 22-18"/><path d="M106 216l22-18 22 18"/><path d="M40 106l18 22-18 22"/><path d="M216 106l-18 22 18 22"/></g></svg>
<span>Light a black stage from the center and grow the crystal large for a macro-photo look — trailing streaks and floating particles keep it alive</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Macro Photography Crystal Scene Demo" tabindex="0">
<canvas class="cd__cv" id="cdCv" width="424" height="180"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">focal distance <b class="cd__val" id="cdVal">1.00</b> · shallow depth of field</span>
<span class="cd__meta cd__meta--dim">createRadialGradient · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매크로 사진 씬(09). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(DLA 성장 → 표면 재배열 + 필름 그레인 + 흐르는 보케)가 만든다.
// 무대는 스튜디오 블랙. 중앙 키 라이트 아래 결정이 얕은 심도(선명 코어·흐린 가지)로 뜬다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음 — 전부 파스텔이거나 어스름 무채색) ──
$flake-ink: #0d0f16; // 스튜디오 블랙 무대
$flake-lamp: #efe3ff; // 키 라이트 색(연한 라벤더 화이트)
$flake-core: #f2f5fb; // 결정 코어 — 서리 화이트
$flake-lilac: #dbd6eb; // 결정 편광 라일락
$flake-mint: #a2d8c4; // 결정 편광 민트
$flake-pink: #eeb8cf; // 결정 편광 핑크
$flake-node: #d0d4e0; // 안개색 글자·테두리
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flake-* 를 받아 쓴다
--flake-ink: #{$flake-ink};
--flake-lamp: #{$flake-lamp};
--flake-core: #{$flake-core};
--flake-lilac: #{$flake-lilac};
--flake-mint: #{$flake-mint};
--flake-pink: #{$flake-pink};
--flake-node: #{$flake-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $flake-node; // 잉크 무대 위 — 안개색 글자(대비 10.6:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flake-pink; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flake-ink;
box-shadow: inset 0 0 0 1px rgba($flake-node, .18);
}
.cd__field:focus-visible { outline: 2px solid $flake-pink; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flake-node, .78); }
.cd__val { color: $flake-mint; font-weight: 700; } // 잉크 위 민트 라벨(대비 9.6:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 결정이 다 자란 한 장만 남는다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flake-ink: #0d0f16;
--flake-lamp: #efe3ff;
--flake-core: #f2f5fb;
--flake-lilac: #dbd6eb;
--flake-mint: #a2d8c4;
--flake-pink: #eeb8cf;
--flake-node: #d0d4e0;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d0d4e0;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #eeb8cf;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d0f16;
box-shadow: inset 0 0 0 1px rgba(208, 212, 224, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #eeb8cf;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(208, 212, 224, 0.78);
}
.cd__val {
color: #a2d8c4;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 매크로 사진 씬(09). DLA 로 결정 하나를 크게 자라게 하고, 촬영 스튜디오처럼 연출한다.
// ① 중앙 radial gradient 키 라이트(현미경 광원) ② 미세 필름 그레인(매 프레임 새로운 노이즈 —
// 어두운 배경이 정적으로 죽지 않게 한다) ③ 결정 본체(grid cells ImageData, 촛점면 안쪽은 선명하게
// 바깥 가지는 DOF 만큼 뿌옇게 — 얕은 심도) ④ 잔광 줄기(라이트에서 비스듬히 흐르는 반짝 먼지)
// ⑤ 결정 주위 부유 입자(포어그라운드 보케 — 크고 흐리게).
// 결정도 멈추지 않는다: 성장이 다 차면 표면이 슬금슬금 재배열되며 결이 계속 꿈틀한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var STICKS_PER_FRAME = 160; // 한 프레임에 붙는 입자 — 2초 안에 결정이 화면을 채우는 타임랩스 속도
var RMAX = 40; // 결정 반경(셀) — 화면의 절반 이상을 차지는 큰 결정
var DOF_CELLS = 26; // 초점면 깊이(셀) — 이 안은 선명, 바깥은 뿌옇다
var RESHUFFLE_RATE = 1.6; // 성장 완료 후 프레임당 표면 재배열 시도(성김) — 결이 꿈틀한다
// ────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
var W = cv.width, H = cv.height; // 내부 424×180 고정
var CELL = 2, GW = W / CELL, GH = H / CELL;
var CX = (GW / 2) | 0, CY = ((GH / 2) | 0) - 4;
// 시드 고정 PRNG — mulberry32
var prngState = 0x31909d >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 scss 의 --flake-* 에서 getComputedStyle 로
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_INK = tokRGB('--flake-ink');
var C_LAMP = tokRGB('--flake-lamp');
var C_CORE = tokRGB('--flake-core');
var C_LILAC = tokRGB('--flake-lilac');
var C_MINT = tokRGB('--flake-mint');
var C_PINK = tokRGB('--flake-pink');
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── grid cells 상태 ──
var frozen = new Uint8Array(GW * GH);
var tStick = new Float32Array(GW * GH); tStick.fill(-1);
var distC = new Float32Array(GW * GH); // 중심 거리(초점 심도 계산용) — 캐시
for (var yy = 0; yy < GH; yy++) for (var xx = 0; xx < GW; xx++) distC[yy * GW + xx] = Math.hypot(xx - CX, yy - CY);
var NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
var NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
function idx(x, y) { return y * GW + x; }
function inside(x, y) { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y) { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y) { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x, y) {
var nx = nbX(y), ny = nbY(y), n = 0;
for (var k = 0; k < 6; k++) {
var ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x, y, now) { frozen[idx(x, y)] = 1; tStick[idx(x, y)] = now; }
// ── DLA 성장 — 팁 바이어스로 가지가 뻗는 큰 결정 ──
var maxR = 2, grown = false, grownAt = 0;
function aggregate(now, budget) {
var stuck = 0, attempts = 0;
while (stuck < budget && attempts < budget * 9) {
attempts++;
var ang = rand() * Math.PI * 2;
var rr = maxR + 6 + rand() * 16;
var x = Math.round(CX + Math.cos(ang) * rr);
var y = Math.round(CY + Math.sin(ang) * rr * 0.95);
var steps = 0;
while (steps < 460) {
steps++;
var dir = (rand() * 6) | 0;
var bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
var n = frozenNb(x, y);
if (n > 0) {
var p = n === 1 ? 1 : n === 2 ? 0.5 : 0.13;
var dist = distC[idx(x, y)];
p *= 0.35 + 0.65 * Math.min(1, dist / (maxR + 4));
if (dist < RMAX && rand() < p) {
stick(x, y, now); stuck++;
if (dist > maxR) maxR = dist;
break;
}
}
}
}
if (maxR >= RMAX - 1.5) { grown = true; grownAt = now; }
}
// ── 재배열 — 완성된 결정의 표면이 느리게 꿈틀(녹았다 붙는 미세 순환) ──
function reshuffle(now) {
for (var i = 0; i < RESHUFFLE_RATE * 10; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 5 || n === 0) continue; // 매립·고립 셀은 그대로
if (distC[id] < maxR * 0.55) continue; // 코어는 안정 — 가장자리만
if (rand() < 0.35) { frozen[id] = 0; } // 떼고
// 그 자리 근처에 다시 붙여 결이 미세하게 밀린다
var nx2 = nbX(y), ny2 = nbY(y);
var k = (rand() * 6) | 0;
var ax = x + nx2[k], ay = y + ny2[k];
if (inside(ax, ay) && !frozen[idx(ax, ay)] && frozenNb(ax, ay) > 0) stick(ax, ay, now);
}
}
// ── grid cells → ImageData(선명 레이어) + 블러 레이어(초점 밖 가지) ──
var sharpCv = document.createElement('canvas'); sharpCv.width = GW; sharpCv.height = GH;
var stx = sharpCv.getContext('2d');
var softCv = document.createElement('canvas'); softCv.width = GW; softCv.height = GH;
var ftx = softCv.getContext('2d');
var imgS = stx.createImageData(GW, GH), pxS = imgS.data;
var imgF = ftx.createImageData(GW, GH), pxF = imgF.data;
function repaintGrids(now) {
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var id = y * GW + x;
var o = id * 4;
if (!frozen[id]) { pxS[o + 3] = 0; pxF[o + 3] = 0; continue; }
var age = now - tStick[id];
var k = Math.min(1, age / 850);
// 결정 축 방향에 따른 은은한 색 분산 — 현미경 편광 느낌의 파스텔
var ang = Math.atan2(y - CY, x - CX);
var tint = (ang > -0.5 && ang <= 0.5) || (ang > Math.PI - 0.5 || ang <= -Math.PI + 0.5) ? C_LILAC
: ang > 0.5 && ang <= 2.1 ? C_MINT : C_PINK;
var lift = frozenNb(x, y) <= 2 ? 24 : 0;
var R = Math.min(255, C_CORE[0] + (tint[0] - C_CORE[0]) * k * 0.55 + lift);
var G = Math.min(255, C_CORE[1] + (tint[1] - C_CORE[1]) * k * 0.55 + lift);
var B = Math.min(255, C_CORE[2] + (tint[2] - C_CORE[2]) * k * 0.55 + lift);
pxS[o] = R; pxS[o + 1] = G; pxS[o + 2] = B; pxS[o + 3] = 255;
// 초점면 안쪽 = 선명 레이어에, 바깥 = 소프트 레이어로 나눠 싣는다
var dof = distC[id] <= DOF_CELLS;
if (dof) { pxF[o + 3] = 0; }
else {
pxF[o] = R; pxF[o + 1] = G; pxF[o + 2] = B; pxF[o + 3] = 255;
pxS[o + 3] = 0;
}
}
}
stx.putImageData(imgS, 0, 0);
ftx.putImageData(imgF, 0, 0);
}
// ── 배경 — 키 라이트 + 숨쉬는 림 + 필름 그레인 ──
var grainCv = document.createElement('canvas'); grainCv.width = W; grainCv.height = H;
var gtx2 = grainCv.getContext('2d');
var gImg = gtx2.createImageData(W, H), gPx = gImg.data;
function repaintGrain() {
// 매 프레임 새로운 미세 노이즈 + 산발적 핫픽셀(센서 반응) — 어두운 면이 정적으로 죽지 않는다
for (var i = 0; i < gPx.length; i += 4) {
var v = 10 + (rand() * 34) | 0;
var hot = rand() < 0.0022 ? 150 + (rand() * 90 | 0) : 0;
gPx[i] = Math.min(255, v + hot); gPx[i + 1] = Math.min(255, v + hot); gPx[i + 2] = Math.min(255, v + 5 + hot);
gPx[i + 3] = 22 + (hot ? 60 : 0);
}
gtx2.putImageData(gImg, 0, 0);
}
// ── 잔광 줄기 — 라이트에서 비스듬히 흐르는 반짝 먼지(부유 입자) ──
var motes = [];
var MOTE_COLS = [C_CORE, C_LILAC, C_MINT, C_PINK];
function initMotes() {
for (var i = 0; i < 78; i++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: 0.12 + rand() * 0.5, vy: -0.05 - rand() * 0.16,
r: 0.7 + rand() * 1.8, ph: rand() * Math.PI * 2,
col: MOTE_COLS[(rand() * MOTE_COLS.length) | 0]
});
}
}
function stepMotes(now) {
for (var i = 0; i < motes.length; i++) {
var mo = motes[i];
mo.x += mo.vx * (0.7 + 0.3 * Math.sin(now / 900 + mo.ph));
mo.y += mo.vy;
if (mo.x > W + 6) { mo.x = -6; mo.y = rand() * H; }
if (mo.y < -6) { mo.y = H + 6; mo.x = rand() * W * 0.6; }
}
}
// ── 합성 ──
function draw(now) {
// 1. 무대 — 잉크 바탕 + 중앙 키 라이트(radial) + 숨쉬는 림 라이트
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1; ctx.filter = 'none';
ctx.fillStyle = rgba(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var key = ctx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 240);
key.addColorStop(0, rgba(C_LAMP, 0.24 + 0.09 * Math.sin(now / 760)));
key.addColorStop(0.55, rgba(C_LILAC, 0.09 + 0.05 * Math.sin(now / 760)));
key.addColorStop(1, rgba(C_LILAC, 0));
ctx.fillStyle = key; ctx.fillRect(0, 0, W, H);
var rimX = W * (0.5 + 0.34 * Math.sin(now / 2900));
var rim = ctx.createRadialGradient(rimX, -30, 0, rimX, -30, 220);
rim.addColorStop(0, rgba(C_MINT, 0.09 + 0.08 * Math.sin(now / 1250 + 1.2)));
rim.addColorStop(1, rgba(C_MINT, 0));
ctx.fillStyle = rim; ctx.fillRect(0, 0, W, H);
// 두 번째 워시 — 반대편에서 도는 핑크 안개(필름의 온도 분산)
var wx2 = W * (0.5 - 0.34 * Math.sin(now / 3400));
var wash = ctx.createRadialGradient(wx2, H + 20, 0, wx2, H + 20, 200);
wash.addColorStop(0, rgba(C_PINK, 0.07 + 0.07 * Math.sin(now / 1000 + 2.6)));
wash.addColorStop(1, rgba(C_PINK, 0));
ctx.fillStyle = wash; ctx.fillRect(0, 0, W, H);
// 2. 필름 그레인 — 매 프레임 새 노이즈
repaintGrain();
ctx.globalAlpha = 0.9;
ctx.drawImage(grainCv, 0, 0);
ctx.globalAlpha = 1;
// 3. 결정 광륜 — 전체 격자를 뿌옇게(할로)
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(7px)';
ctx.globalAlpha = 0.30 + 0.07 * Math.sin(now / 880);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(sharpCv, 0, 0, W, H);
ctx.drawImage(softCv, 0, 0, W, H);
// 4. 초점면 바깥 가지 — 소프트 레이어(얕은 심도)
ctx.filter = 'blur(2.6px)';
ctx.globalAlpha = 0.9;
ctx.drawImage(softCv, 0, 0, W, H);
// 5. 결정 본체 — 초점면 안쪽 선명 픽셀
ctx.filter = 'none'; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.imageSmoothingEnabled = false;
ctx.drawImage(sharpCv, 0, 0, W, H);
// 6. 잔광 줄기 + 부유 입자 — 흐르는 보케
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < motes.length; i++) {
var mo = motes[i];
var tw = 0.45 + 0.55 * Math.sin(now / 260 + mo.ph * 3);
var a = (mo.r > 1.6 ? 0.10 : 0.30) * tw;
if (mo.r > 1.6) {
var g = ctx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, mo.r * 4);
g.addColorStop(0, rgba(mo.col, a));
g.addColorStop(1, rgba(mo.col, 0));
ctx.fillStyle = g;
ctx.fillRect(mo.x - mo.r * 4, mo.y - mo.r * 4, mo.r * 8, mo.r * 8);
} else {
ctx.fillStyle = rgba(mo.col, a);
ctx.beginPath();
ctx.arc(mo.x, mo.y, mo.r, 0, Math.PI * 2);
ctx.fill();
}
}
// 7. 비네트 — 매크로 렌즈의 어두운 가장자리. 살짝 숨쉬게 해서 테두리도 죽지 않게
ctx.globalCompositeOperation = 'source-over';
var vg = ctx.createRadialGradient(W / 2, H / 2, 95, W / 2, H / 2, 250);
vg.addColorStop(0, rgba(C_INK, 0));
vg.addColorStop(1, rgba(C_INK, 0.50 + 0.05 * Math.sin(now / 1500)));
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
var t0 = -1;
var dofT = -1;
function frame(nowRaw) {
if (t0 < 0) t0 = nowRaw;
var now = nowRaw - t0;
if (!grown) aggregate(now, STICKS_PER_FRAME);
else reshuffle(now);
stepMotes(now);
repaintGrids(now);
draw(now);
// focal distance 라벨 — 미세하게 흔들리는 랙킹(촛점 당김)
if (now - dofT > 120) { dofT = now; valOut.textContent = (0.96 + 0.08 * Math.abs(Math.sin(now / 1700))).toFixed(2); }
requestAnimationFrame(frame);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
initMotes();
stick(CX, CY, -9000);
var nx0 = nbX(CY), ny0 = nbY(CY);
for (var k0 = 0; k0 < 6; k0++) {
var ax0 = CX + nx0[k0], ay0 = CY + ny0[k0];
if (inside(ax0, ay0)) stick(ax0, ay0, -9000);
}
if (reduce) {
aggregate(0, 4000); // 다 자란 한 장
repaintGrids(2000);
draw(2000);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// MacroPhotoScene — vanilla 판(run/319/demo/09-snowflake-macro-photo-scene/index.html) 의 React 포트.
// 검은 스튜디오 무대에 중앙 radial gradient 키 라이트를 깔고 DLA 결정을 크게 키운다.
// 초점면 안쪽(선명 레이어)과 바깥(소프트 레이어)을 나눠 얕은 심도를 만들고,
// 필름 그레인(매 프레임 새 노이즈)과 흐르는 보케 입자로 배경이 계속 살아 움직인다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MacroPhotoScene.module.scss";
export type MacroPhotoSceneProps = {
duration?: string;
easing?: string;
color?: string;
sticksPerFrame?: number; // 한 프레임에 붙는 입자
rMax?: number; // 결정 반경(셀)
dofCells?: number; // 초점면 깊이(셀) — 이 안은 선명, 바깥은 뿌옇다
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Mote = { x: number; y: number; vx: number; vy: number; r: number; ph: number; col: RGB };
type Colors = { ink: RGB; lamp: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB };
const FIELD_LABEL = "매크로 사진 결정 씬 실험판";
const RESHUFFLE_RATE = 0.9;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const NX_EVEN = [1, -1, 0, -1, 0, -1], NY_EVEN = [0, 0, -1, -1, 1, 1];
const NX_ODD = [1, -1, 0, 1, 0, 1], NY_ODD = [0, 0, -1, -1, 1, 1];
export default function MacroPhotoScene({
duration = "2s",
easing = "ease-out",
color = "#b8a7ea",
sticksPerFrame = 26,
rMax = 40,
dofCells = 26,
}: MacroPhotoSceneProps) {
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<Colors | null>(null);
const randRef = useRef(makePRNG(0x31909d)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const W = 424, H = 180, CELL = 2, GW = W / CELL, GH = H / CELL;
const CX = Math.floor(GW / 2), CY = Math.floor(GH / 2) - 4;
const frozenRef = useRef<Uint8Array>(new Uint8Array(GW * GH)) as Box<Uint8Array>;
const tStickRef = useRef<Float32Array>(new Float32Array(GW * GH)) as Box<Float32Array>;
const distCRef = useRef<Float32Array>(new Float32Array(GW * GH)) as Box<Float32Array>;
const motesRef = useRef<Mote[]>([]) as Box<Mote[]>;
const maxRRef = useRef(2) as Box<number>;
const grownRef = useRef(false) as Box<boolean>;
const sharpCvRef = useRef<HTMLCanvasElement | null>(null);
const softCvRef = useRef<HTMLCanvasElement | null>(null);
const grainCvRef = useRef<HTMLCanvasElement | null>(null);
function idx(x: number, y: number): number { return y * GW + x; }
function inside(x: number, y: number): boolean { return x >= 0 && x < GW && y >= 0 && y < GH; }
function nbX(y: number): number[] { return (y & 1) ? NX_ODD : NX_EVEN; }
function nbY(y: number): number[] { return (y & 1) ? NY_ODD : NY_EVEN; }
function frozenNb(x: number, y: number): number {
const frozen = frozenRef.current;
const nx = nbX(y), ny = nbY(y);
let n = 0;
for (let k = 0; k < 6; k++) {
const ax = x + nx[k], ay = y + ny[k];
if (inside(ax, ay) && frozen[idx(ax, ay)]) n++;
}
return n;
}
function stick(x: number, y: number, now: number): void {
frozenRef.current[idx(x, y)] = 1;
tStickRef.current[idx(x, y)] = now;
}
function aggregate(now: number, budget: number): void {
const rand = randRef.current, frozen = frozenRef.current, distC = distCRef.current;
let stuck = 0, attempts = 0;
while (stuck < budget && attempts < budget * 9) {
attempts++;
const ang = rand() * Math.PI * 2;
const rr = maxRRef.current + 6 + rand() * 16;
let x = Math.round(CX + Math.cos(ang) * rr);
let y = Math.round(CY + Math.sin(ang) * rr * 0.95);
let steps = 0;
while (steps < 460) {
steps++;
const dir = (rand() * 6) | 0;
const bx = nbX(y), by = nbY(y);
x += bx[dir]; y += by[dir];
if (!inside(x, y)) break;
if (frozen[idx(x, y)]) { x -= bx[dir]; y -= by[dir]; continue; }
const n = frozenNb(x, y);
if (n > 0) {
let p = n === 1 ? 1 : n === 2 ? 0.5 : 0.13;
const dist = distC[idx(x, y)];
p *= 0.35 + 0.65 * Math.min(1, dist / (maxRRef.current + 4));
if (dist < rMax && rand() < p) {
stick(x, y, now); stuck++;
if (dist > maxRRef.current) maxRRef.current = dist;
break;
}
}
}
}
if (maxRRef.current >= rMax - 1.5) grownRef.current = true;
}
function reshuffle(now: number): void {
const rand = randRef.current, frozen = frozenRef.current, distC = distCRef.current;
for (let i = 0; i < RESHUFFLE_RATE * 10; i++) {
const x = (rand() * GW) | 0, y = (rand() * GH) | 0;
const id = idx(x, y);
if (!frozen[id]) continue;
const n = frozenNb(x, y);
if (n >= 5 || n === 0) continue;
if (distC[id] < maxRRef.current * 0.55) continue;
if (rand() < 0.35) frozen[id] = 0;
const nx2 = nbX(y), ny2 = nbY(y);
const k = (rand() * 6) | 0;
const ax = x + nx2[k], ay = y + ny2[k];
if (inside(ax, ay) && !frozen[idx(ax, ay)] && frozenNb(ax, ay) > 0) stick(ax, ay, now);
}
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function repaintGrids(now: number): void {
const sharpCv = sharpCvRef.current, softCv = softCvRef.current, col = colRef.current;
if (!sharpCv || !softCv || !col) return;
const stx = sharpCv.getContext("2d"), ftx = softCv.getContext("2d");
if (!stx || !ftx) return;
const frozen = frozenRef.current, tStick = tStickRef.current, distC = distCRef.current;
const imgS = stx.createImageData(GW, GH), pxS = imgS.data;
const imgF = ftx.createImageData(GW, GH), pxF = imgF.data;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const id = y * GW + x, o = id * 4;
if (!frozen[id]) { pxS[o + 3] = 0; pxF[o + 3] = 0; continue; }
const age = now - tStick[id];
const k = Math.min(1, age / 850);
const ang = Math.atan2(y - CY, x - CX);
const tint: RGB = (ang > -0.5 && ang <= 0.5) || (ang > Math.PI - 0.5 || ang <= -Math.PI + 0.5) ? col.lilac
: ang > 0.5 && ang <= 2.1 ? col.mint : col.pink;
const lift = frozenNb(x, y) <= 2 ? 24 : 0;
const R = Math.min(255, col.core[0] + (tint[0] - col.core[0]) * k * 0.55 + lift);
const G = Math.min(255, col.core[1] + (tint[1] - col.core[1]) * k * 0.55 + lift);
const B = Math.min(255, col.core[2] + (tint[2] - col.core[2]) * k * 0.55 + lift);
pxS[o] = R; pxS[o + 1] = G; pxS[o + 2] = B; pxS[o + 3] = 255;
if (distC[id] <= dofCells) { pxF[o + 3] = 0; }
else {
pxF[o] = R; pxF[o + 1] = G; pxF[o + 2] = B; pxF[o + 3] = 255;
pxS[o + 3] = 0;
}
}
}
stx.putImageData(imgS, 0, 0);
ftx.putImageData(imgF, 0, 0);
}
function repaintGrain(): void {
const grainCv = grainCvRef.current;
if (!grainCv) return;
const gtx = grainCv.getContext("2d");
if (!gtx) return;
const rand = randRef.current;
const gImg = gtx.createImageData(W, H), gPx = gImg.data;
for (let i = 0; i < gPx.length; i += 4) {
const v = 10 + ((rand() * 26) | 0);
gPx[i] = v; gPx[i + 1] = v; gPx[i + 2] = v + 4; gPx[i + 3] = 14;
}
gtx.putImageData(gImg, 0, 0);
}
function initMotes(): void {
const col = colRef.current;
if (!col) return;
const rand = randRef.current;
const cols: RGB[] = [col.core, col.lilac, col.mint, col.pink];
const motes = motesRef.current;
for (let i = 0; i < 46; i++) {
motes.push({
x: rand() * W, y: rand() * H,
vx: 0.12 + rand() * 0.5, vy: -0.05 - rand() * 0.16,
r: 0.7 + rand() * 1.5, ph: rand() * Math.PI * 2,
col: cols[(rand() * cols.length) | 0],
});
}
}
function stepMotes(now: number): void {
const motes = motesRef.current;
for (let i = 0; i < motes.length; i++) {
const mo = motes[i];
mo.x += mo.vx * (0.7 + 0.3 * Math.sin(now / 900 + mo.ph));
mo.y += mo.vy;
if (mo.x > W + 6) { mo.x = -6; mo.y = Math.random() * H; }
if (mo.y < -6) { mo.y = H + 6; mo.x = Math.random() * W * 0.6; }
}
}
function draw(now: number): void {
const ctx = ctxRef.current, col = colRef.current;
const sharpCv = sharpCvRef.current, softCv = softCvRef.current, grainCv = grainCvRef.current;
if (!ctx || !col || !sharpCv || !softCv || !grainCv) return;
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1; ctx.filter = "none";
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const key = ctx.createRadialGradient(CX * CELL, CY * CELL, 0, CX * CELL, CY * CELL, 210);
key.addColorStop(0, rgba(col.lamp, 0.20 + 0.035 * Math.sin(now / 760)));
key.addColorStop(0.55, rgba(col.lilac, 0.05));
key.addColorStop(1, rgba(col.lilac, 0));
ctx.fillStyle = key; ctx.fillRect(0, 0, W, H);
const rimX = W * (0.5 + 0.34 * Math.sin(now / 2900));
const rim = ctx.createRadialGradient(rimX, -30, 0, rimX, -30, 190);
rim.addColorStop(0, rgba(col.mint, 0.06 + 0.035 * Math.sin(now / 1250 + 1.2)));
rim.addColorStop(1, rgba(col.mint, 0));
ctx.fillStyle = rim; ctx.fillRect(0, 0, W, H);
repaintGrain();
ctx.globalAlpha = 0.9;
ctx.drawImage(grainCv, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "lighter";
ctx.filter = "blur(7px)";
ctx.globalAlpha = 0.30 + 0.07 * Math.sin(now / 880);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(sharpCv, 0, 0, W, H);
ctx.drawImage(softCv, 0, 0, W, H);
ctx.filter = "blur(2.6px)";
ctx.globalAlpha = 0.9;
ctx.drawImage(softCv, 0, 0, W, H);
ctx.filter = "none"; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(sharpCv, 0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
const motes = motesRef.current;
for (let i = 0; i < motes.length; i++) {
const mo = motes[i];
const tw = 0.45 + 0.55 * Math.sin(now / 260 + mo.ph * 3);
const a = (mo.r > 1.6 ? 0.10 : 0.30) * tw;
if (mo.r > 1.6) {
const g = ctx.createRadialGradient(mo.x, mo.y, 0, mo.x, mo.y, mo.r * 4);
g.addColorStop(0, rgba(mo.col, a));
g.addColorStop(1, rgba(mo.col, 0));
ctx.fillStyle = g;
ctx.fillRect(mo.x - mo.r * 4, mo.y - mo.r * 4, mo.r * 8, mo.r * 8);
} else {
ctx.fillStyle = rgba(mo.col, a);
ctx.beginPath();
ctx.arc(mo.x, mo.y, mo.r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalCompositeOperation = "source-over";
const vg = ctx.createRadialGradient(W / 2, H / 2, 95, W / 2, H / 2, 250);
vg.addColorStop(0, rgba(col.ink, 0));
vg.addColorStop(1, rgba(col.ink, 0.50 + 0.05 * Math.sin(now / 1500)));
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
useEffect(() => {
const root = rootRef.current, cv = cvRef.current;
if (!root || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
sharpCvRef.current = document.createElement("canvas");
sharpCvRef.current.width = GW; sharpCvRef.current.height = GH;
softCvRef.current = document.createElement("canvas");
softCvRef.current.width = GW; softCvRef.current.height = GH;
grainCvRef.current = document.createElement("canvas");
grainCvRef.current.width = W; grainCvRef.current.height = H;
function tokRGB(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)];
}
colRef.current = {
ink: tokRGB("--flake-ink"), lamp: tokRGB("--flake-lamp"), core: tokRGB("--flake-core"),
lilac: tokRGB("--flake-lilac"), mint: tokRGB("--flake-mint"), pink: tokRGB("--flake-pink"),
};
randRef.current = makePRNG(0x31909d);
frozenRef.current = new Uint8Array(GW * GH);
tStickRef.current = new Float32Array(GW * GH).fill(-1);
const distC = distCRef.current;
for (let yy = 0; yy < GH; yy++) {
for (let xx = 0; xx < GW; xx++) {
distC[yy * GW + xx] = Math.hypot(xx - CX, yy - CY);
}
}
motesRef.current = [];
maxRRef.current = 2;
grownRef.current = false;
initMotes();
stick(CX, CY, -9000);
const nx0 = nbX(CY), ny0 = nbY(CY);
for (let k0 = 0; k0 < 6; k0++) {
const ax0 = CX + nx0[k0], ay0 = CY + ny0[k0];
if (inside(ax0, ay0)) stick(ax0, ay0, -9000);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let t0 = -1;
function frame(nowRaw: number): void {
if (t0 < 0) t0 = nowRaw;
const now = nowRaw - t0;
if (!grownRef.current) aggregate(now, sticksPerFrame);
else reshuffle(now);
stepMotes(now);
repaintGrids(now);
draw(now);
rafRef.current = requestAnimationFrame(frame);
}
if (reduce) {
aggregate(0, 4000);
repaintGrids(2000);
draw(2000);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sticksPerFrame, rMax, dofCells]);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.cd} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth={14} strokeLinecap="round"><path d="M128 26v204" /><path d="M43 74l170 128" /><path d="M213 74L43 202" /><path d="M106 40l22 18 22-18" /><path d="M106 216l22-18 22 18" /><path d="M40 106l18 22-18 22" /><path d="M216 106l-18 22 18 22" /></g></svg>
<span>검은 무대에 중앙 조명을 깔고 결정을 크게 키우면 현미경 매크로의 한 컷이 됩니다 — 잔광 줄기와 부유 입자가 프레임을 계속 살아 움직이게 합니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>초점 거리 <b className={styles.val}>1.00</b> · 얕은 심도</span>
<span className={styles.metaDim}>createRadialGradient · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · Macro-photo scene — one flake in shallow depth of field on a dark stage — Measured area 36.536% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
reshuffle keeps a finished crystal's rim quietly cycling
// ── Reshuffle — a finished crystal's surface slowly wriggles (a fine melt-and-restick cycle) ──
function reshuffle(now) {
for (var i = 0; i < RESHUFFLE_RATE * 10; i++) {
var x = (rand() * GW) | 0, y = (rand() * GH) | 0;
var id = idx(x, y);
if (!frozen[id]) continue;
var n = frozenNb(x, y);
if (n >= 5 || n === 0) continue; // buried or isolated cells stay put
if (distC[id] < maxR * 0.55) continue; // the core is stable — only the rim moves
if (rand() < 0.35) { frozen[id] = 0; } // peel it
// re-stick it nearby so the texture shifts by a hair
var nx2 = nbX(y), ny2 = nbY(y);
var k = (rand() * 6) | 0;
var ax = x + nx2[k], ay = y + ny2[k];
if (inside(ax, ay) && !frozen[idx(ax, ay)] && frozenNb(ax, ay) > 0) stick(ax, ay, now);
}
}
Filling a pre-drawn outline is coloring, not diffusion-limited aggregation — where it breaks
Pre-drawing an outline and filling it isn't DLA
A hexagonal shape that comes from filling particles inside a pre-drawn snowflake outline is coloring, not aggregation — the hollow concave corners and sharp tips in every item on this page are the direct output of the contact rule, not decoration applied afterward. Skip the random walk and touching check and there's nothing left to call diffusion-limited.
Ballistic particles produce a dense blob, not tips
Firing particles straight at the center instead of letting them wander removes the "diffusion" from diffusion-limited aggregation — a random walk is what lets particles get turned away from the interior and caught at a tip instead, and a straight shot produces a dense, tip-free blob no matter how the sticking probability is tuned.
A square 4-neighbor grid fakes hex symmetry badly
Items 01 through 03 read neighbors off row-parity DI/DJ tables built for a true hex grid, and item 04's angle-folding assumes the same six-direction geometry underneath. Simulating on a square grid and rotating a copy 60 degrees afterward doesn't reproduce that texture — the walk itself has to move in six directions, not four.
clearRect every frame erases the afterglow that keeps 30%+ painted
None of the nine items call clearRect on the crystal layer — a darkening overlay hangs over it instead, so drifted particles' trails fade in as afterglow rather than vanishing outright. Redraw from scratch every frame and painted area drops well under the 34-45% range the other eight items measure (06's colored-crystal-only frame runs a deliberately narrower 9.3%, covered in the FAQ below), along with the 23-of-23 transitioning frames.
Why snowflakes grow by DLA? — random-walk aggregation and six-fold symmetry
A random walk that freezes on contact is the whole mechanism
Every item on this page traces back to one operation: launch a particle from outside the crystal, let it take a random walk across a hex grid, and freeze it the instant it touches an already-frozen cell. 01 runs that operation with STICK=1.0 and nothing else, and the result is a snowflake generator online free of any pre-drawn outline: hollow concave corners and sharp tips that the contact rule alone produces — the same diffusion-limited aggregation Witten and Sander described for how frost and mineral dendrites actually form.
Tip bias is what turns a blob into a branch
02 doesn't add a second mechanism for its dendrite arms — it re-rolls the same contact probability by how far the new cell sits from the seed, so tips reaching outward catch particles far more easily than concave interior spots. That single-parameter bias (TIP_BIAS=2.6) is a simplified version of the Mullins-Sekerka instability, the real reason a growing ice surface amplifies any small bump into a full branch instead of smoothing itself flat.
One bias width maps the whole Nakaya diagram
03 changes nothing about the aggregation rule between its fern and its needle crystal except the width of that bias curve — a narrow gap between tip and interior odds fills out sideways into a fern, a wide gap starves the interior and grows long needles. Compressed into one number, that's the same axis Ukichiro Nakaya's habit diagram maps across temperature and supersaturation for real snow crystals.
Folding a sector is geometry, not a second simulation
04 never runs six separate crystals to get six-fold symmetry — it folds every particle's angle onto a 60-degree period so the random walk only ever explores one wedge, then stamps that wedge out through 12 rotated-and-mirrored copies. The D6 symmetry real snowflakes show isn't imposed after the fact here; it's the boundary condition the walk obeys while it grows.
Three variants that swap bias width and mirror count, tuned for reduced-motion
One constant, three different crystals
| Variant | Changed value | What it feels like |
|---|---|---|
| Dendrite bias | 02 tip bias 0.3 → 0.6 | Branches pack tighter, closer to a real snowflake's lace |
| Aggregation speed | 01 particle rate 220/sec → 700/sec | The crystal visibly speeds up and the demo runs shorter |
| Symmetry fold | 04 sector angle 60° → 30° | Half the sector doubles the stamped copies and thins the branches |
Nothing here is drawn by hand, so widening one constant reshapes the whole crystal instead of asking someone to redraw a snowflake generator svg frame by frame.
What reduced-motion keeps
A @media (prefers-reduced-motion: reduce) check decides whether the requestAnimationFrame loop ever starts — when a visitor has asked for less motion, every item draws one fixed frame instead and adds an .is-still class to its canvas. Growth stops, but the crystal itself, mid-formation, still renders rather than leaving a blank panel.
Pointer input never blocks a keyboard visitor
07's pointer seeding reads pointerdown on the field element, which also carries a tabindex so a keyboard visitor landing on the demo isn't stuck behind a mouse-only interaction — without pointer input, AUTO_SEEDS plants seeds on a fixed timetable instead. The other eight items read no pointer or keyboard input at all, since their growth runs entirely on a clock. Every code block above is copied from the running demo's actual source, fcyyjg6v not retyped, and each item's full file (HTML, CSS, and JS) sits inside the zip behind the password on this page.
Questions before you wire a DLA snowflake into a page
How is a DLA snowflake generator different from a snowflake generator svg tool
A snowflake generator svg tool typically draws one fixed shape from a formula and exports it as a static vector — nothing here is pre-drawn. Every crystal on this page is the pixel-by-pixel outcome of thousands of random-walk particles freezing on contact frame after frame, rendered live to a canvas rather than composed as vector paths.
Can I paste this snowflake generator code in and use it as-is
Yes — nothing in this set of nine reaches out to an external library, WebGL, or CDN, so the canvas markup and script tag can be lifted into any page and it grows the same crystal it does here.
Why does item 06 paint the smallest area but still read so vivid
Item 06 records the lowest painted area of the nine at 9.3% because it colors only the crystal body itself, with no snow, haze, or bloom layered over an empty stage the way the other eight items fill their frame. That same narrow focus is what pushes its average frame intensity to 73.3, second only to item 03's needle-and-fern pair at 75.9 — every lit pixel belongs to the crystal, so the color reads at near-full strength.
Sources: MDN — CanvasRenderingContext2D · MDN — ImageData · MDN — Window.requestAnimationFrame() · MDN — prefers-reduced-motion · Wikipedia — Diffusion-limited aggregation · Wikipedia — Snowflake · a value-noise elevation field folded the same way item 05 folds a temperature field · a reaction-diffusion field that grows Turing patterns from the same neighbor-counting logic as item 06's rings.
A photo-style hero and a click-to-grow crystal — where these nine actually get used
A winter campaign or a snow-themed landing page reaches for item 09 first — one flake staged like microscopy on a dark background is already a finished shot, so a headline is the only thing left to add. A hero area built for visitors who like to click does better with item 07: a second crystal competing at the pointer turns a passive diagram into something a reader triggers.
A science write-up explaining how humidity shapes a crystal is where item 03 belongs — sliding one bias-width value carries the shape from a fern to a needle in view, with no separate chart needed. Each of the three lives in a single file with nothing to compile first, so any one of them drops into a static section exactly as it is.









