04六重対称ミラー — 60度ごとに折りたたみ6倍速で育つ
1つのセクタ(60度扇)だけシミュレートし残り5つを回転コピーすると、計算量は1/6で対称の狂わない完璧な結晶になります。
使いどころローディング進行の結晶、節約が必要な大きいキャンバス
rotateImageDatarequestAnimationFrame

<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>60度の扇形セクターひとつだけをシミュレーションし、残りは回転・鏡映コピーします — 計算量は1/6、対称のずれない六角結晶が育ちます</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="六重対称結晶実験版" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">セクター <b class="cd__val" id="cdSector"></b> · スタンプ <b class="cd__val" id="cdCopies"></b> · 凝集 <b class="cd__val" id="cdRate"></b>/秒</span>
<span class="cd__meta cd__meta--dim">キャンバス · 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도 セクター 하나에서만 DLA 랜덤워크를 돌린다. 입자가 섹터를 벗어나면
// 각도를 60도 주기로 접어(회전 접기) 안으로 되돌려 보내고, 붙은 셀은 화면에 그릴 때
// 여섯 방향 회전 + 거울(±각) 도장 12장으로 펼친다. 시뮬레이션 비용은 1/6 인데 대칭은
// 픽셀 하나 안 어긋난다 — 눈결정이 원래 가진 D6 대칭(회전 6 × 거울) 그대로.
// 화면은 매 프레임 배경 그라디언트 + 오로라 맥동 + 부유 입자 + 결정 도장을 ImageData 한 장으로 다시 쓴다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var SECTOR_DEG = 60; // セクター 각(도) — 변형 "대칭 접기" 축(60 → 30, 도장 수는 360/각×2)
var SPS = 320; // 초당 응집 입자 수 — 변형 "여섯 배 속도" 축(90 → 320)
var N = 44; // セクター 格子 반경(셀 수) — 셀이 커질수록 결정이 화면을 넓게 채운다
// ────────────────────────────────────────────────────
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;
}
}
// ── セクター 格子 — 꼭짓점(결정 중심)을 원점, +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;
// 각도를 セクター(-SECTOR/2 .. SECTOR/2)로 60도 周期 접는다 — 회전 접기(C6). 걸음이 부채꼴을
// 나가도 접혀 안으로 되돌아오므로 시뮬레이션 공간이 セクター 하나로 끝난다.
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;
}
}
// ② 결정 도장 — セクター 셀 하나를 회전 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 六重対称ミラー — 60度ごとに折りたたみ6倍速で育つ — 計測 累積面積 40.29% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · snowflake generator 9種 — DLAで育つ雪結晶
このデモが属する記事
snowflake generator 9種 — DLAで育つ雪結晶snowflake generatorとは、雪片の絵を貼り付ける作業ではなく、粒子が結晶に触れて凍りつく規則を何千回も繰り返して形を育てる計算のことです。ライブラリ·WebGL なしで、外から飛んできた粒子1つが結晶に触れて付く瞬間を繰り返 この記事のデモをすべて見る


