ボイド シミュレーション9選 — キャンバスで描く鳥の群れ
ボイド シミュレーションとは、近傍の個体どうしが分離・整列・結合という3つのベクトルを足し合わせ、リーダーのいない鳥の群れを描くアルゴリズムです。ライブラリ·WebGLなし、3つのベクトル規則だけで数百羽の鳥の群れを、キャンバスと素のJavaScriptだけで動かす9枚がここにあります。
01 flock-three-rules
02 flock-bird-silhouette-wingbeat
03 flock-fish-school-depth
04 flock-predator-attack
05 flock-spatial-hash-grid
06 flock-murmuration-flowline
07 flock-attractor-orbit
08 flock-glow-plankton
09 flock-text-glyph-swarm
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 3規則の群れ — 分離·整列·結合だけで回す最小の群れ
- 02 鳥の群れシルエット — 翼の羽ばたき位相がずれる羽毛
- 03 魚の群れ — 奥行きのあるスクーリング
- 04 捕食者の登場 — 逃走ベクトルが群れを割る
- 05 千羽の計算 — 空間ハッシュで近傍を探す
- 06 マミュレーション — 流線を残す黒い雲
- 07 餌を中心に — 引力の周りを回る軌道
- 08 発光プランクトン — 夜の海で光る群れ
- 09 文字を成す群れ — ポインタを避けて集まる文章
3つの規則が九つの群れに枝分かれする理由
並びは、群れに加わる力の数の順です。01は分離・整列・結合の3ベクトルだけで回す最小の群れで、02と03は同じ3規則を鳥の羽ばたきと魚の奥行きという皮で包みます。04で4本目の逃走ベクトルが割って入り、05は同じ振る舞いのまま千羽をさばく空間ハッシュに道具を替え、06は風の流れ場、07は引力の軌道へ土台を置き換えます。08は夜の海の発光、09は目標点を文字の輪郭へ挿げ替えて群れの行先そのものを変えます。「群れ シミュレーション」で探す方は規則の講義より画面の育ちを先に見たいはずなので、この順に並べました。個体どうしが独立には動かず、Verletばねの網で形を保つ柔らかい物体の盤や格子ごとに流れる流体の盤とは軸が違い、ボイドは「この近くに誰がいるか」だけを材料にします。
群れはなぜ3規則で作るのか — 分離·整列·結合と近傍半径の計算
リーダーのいない整列
1987年のSIGGRAPHでCraig Reynoldsが発表したBoidsは、「あちらへ飛べ」と指示する個体を1匹も置かずに群れの絵を作りました。各個体は見える範囲の隣人の統計 — 平均速度・重心・近すぎる個体 — だけから次の進路を出し、群れの形はその集積として現れます。01では代表個体に3本の矢印が灯り、この計算が毎フレーム見えます。
半径が群れの密度を決める
R_NEIGH 42px(01)では隣人が密集して畑のような塊になり、52px(02)では羽の群れが横へ広がり、26px(06)では雲が薄くほぐれます。半径を広げるほど1個体が見る隣人が増え、結合の重心が遠くまで及ぶため塊が大きくなります。分離は01では半径の半分 21px に限る二段構えで、近すぎる相手だけを強く押す重み w の分数式が肌理を決めます。
重みの比が模様を分ける
同じ3規則でも W_SEP と W_ALI と W_COH の比で群れの顔が変わります。04は W_SEP 1.7 と分離を強めて裂け目を見せ、03は W_COH 0.6 と結合を弱めてゆるい学校にします。「鳥 の 群れ シミュレーション」で検索する方の求める絵に一番近いのは01と02の3規則のままの群れで、04以降は力を足して物語を乗せた発展形です。
項目コード
013規則の群れ — 分離·整列·結合だけで回す最小の群れ
個体は毎フレーム半径 R_NEIGH 42px の隣人だけを見て、離れる・向きを揃える・中心へ寄る3つのベクトルを足して動く、260羽の最小の盤です。

<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="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>1体は3つのベクトルの合計で動きます — 分離(近づくと反発)・整列(向きを合わせる)・結合(中心に集まる)。代表個体には矢印が点灯します</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="3ルール群れ実験パネル" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">260</b>体・分離・整列・結合</span>
<span class="cd__meta cd__meta--dim">キャンバス・ステアリング・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세 규칙의 떼(01). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(세 스티어링 벡터 합산 + 토러스 랩)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
// 힘 벡터 시각화 색 — 본문 토큰 재사용
$flock-sep: #b9ddd1; // 분리 — 민트 계열(밝게 들어올림, BD26 파스텔 정정 S≈.35)
$flock-ali: #dae3ed; // 정렬 — 하늘 계열(BD26 파스텔 정정 L≈.89)
$flock-coh: #ffb9c8; // 결합 — 핑크 계열
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-sep: #{$flock-sep};
--flock-ali: #{$flock-ali};
--flock-coh: #{$flock-coh};
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: $flock-mist; // 잉크 무대 위 — 먼 개체 톤 글자
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flock-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($flock-mist, .72); }
.cd__val { color: $flock-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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-sep: #b9ddd1;
--flock-ali: #dae3ed;
--flock-coh: #ffb9c8;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
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, 220, 230, 0.72);
}
.cd__val {
color: #b9a0ff;
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). 보이드 최소 구현 — 개체마다 이웃 반경을 훑어 세 스티어링 벡터를 계산한다.
// 분리(separation): 반경의 절반 안에 든 가까운 이웃에서 멀어지는 쪽으로 민다(가까울수록 큰 가중).
// 정렬(alignment): 이웃 속도의 평균 방향으로 자기 속도를 스티어.
// 결합(cohesion): 이웃 질량중심 방향으로 스티어.
// 셋을 무게로 합하고 최대 힘·최대 속도로 클램프한다. 화면은 토러스 랩 — 빠져나가면 반대편.
// 대표 개체 1마리엔 세 벡터를 얇은 화살표로 겹쳐 그려 규칙을 눈으로 읽게 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 260; // 개체 수 — 변형 "개체 수" 축(260 → 640)
var W_SEP = 1.5; // 분리 무게
var W_ALI = 1.0; // 정렬 무게
var W_COH = 1.0; // 결합 무게
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 떼로 흐른다
var SEED = 0x30c101;
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 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('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_SEPL = tokRGB('--flock-sep');
var C_ALIG = tokRGB('--flock-ali');
var C_COHE = tokRGB('--flock-coh');
var C_LILA = tokRGB('--flock-lilac');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.6; // reduce 에서 그리는 시각 — 떼가 퍼진 한 장
// ── 시뮬 상수 ──
var R_NEIGH = 42; // 이웃 반경 (px)
var MAX_SPD = 128; // 최대 속도 (px/s)
var MAX_FOR = 340; // 최대 힘 (px/s^2) — 스티어 예리함
// ── 개체 ──
var boids = [];
function spawnB(b) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.ph = rand() * TAU; // 안내 위상 — 개체마다 궤적을 어긋나게
b.grp = arguments.length > 1 ? arguments[1] % 4 : 0; // 하위 무리 4개 — 각자 다른 궤적
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
var view = { sep: [0, 0], ali: [0, 0], coh: [0, 0] }; // 대표 개체 시각화용 최신값
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게 (1/d 가중)
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
// 정렬 — 이웃 평균 속도 방향으로
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
// 결합 — 이웃 질량중심으로
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
if (i === 0) { // 대표 개체 — 화살표 그릴 최신값
view.sep[0] = W_SEP * (sepX !== 0 || sepY !== 0 ? sC[0] : 0);
view.sep[1] = W_SEP * (sepY !== 0 || sepX !== 0 ? sC[1] : 0);
view.ali[0] = cnt > 0 ? W_ALI * sA[0] : 0;
view.ali[1] = cnt > 0 ? W_ALI * sA[1] : 0;
view.coh[0] = cnt > 0 ? W_COH * sB[0] : 0;
view.coh[1] = cnt > 0 ? W_COH * sB[1] : 0;
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;
}
}
function arrow(x, y, vx, vy, scale, col, wdt) {
var ex = x + vx * scale, ey = y + vy * scale;
ctx.strokeStyle = col;
ctx.lineWidth = wdt;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex, ey);
ctx.stroke();
// 화살촉 — 벡터 방향으로 작은 갈래
var ang = Math.atan2(vy, vx);
var hl = 3.2 + wdt;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang - 0.5) * hl, ey - Math.sin(ang - 0.5) * hl);
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang + 0.5) * hl, ey - Math.sin(ang + 0.5) * hl);
ctx.stroke();
}
function draw(tSec) {
// 무대 — 어스름 잉크
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 떼 — 사각 점
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
ctx.fillStyle = 'rgb(' + C_MIST[0] + ',' + C_MIST[1] + ',' + C_MIST[2] + ')';
ctx.fillRect(b.x - 1.5, b.y - 2, 3, 4);
}
// 대표 개체 0번 — 세 힘 벡터 화살표. 무대를 넓게 훑도록 개체 0의 속도에 느린 회전을 얹는다
var hero = boids[0];
if (hero) {
// 대표 개체 몸 — 라일락 강조
ctx.fillStyle = 'rgb(' + C_LILA[0] + ',' + C_LILA[1] + ',' + C_LILA[2] + ')';
ctx.fillRect(hero.x - 2, hero.y - 3, 4, 6);
var sc = 0.24;
arrow(hero.x, hero.y, view.sep[0], view.sep[1], sc, 'rgb(' + C_SEPL[0] + ',' + C_SEPL[1] + ',' + C_SEPL[2] + ')', 1.4);
arrow(hero.x, hero.y, view.ali[0], view.ali[1], sc, 'rgb(' + C_ALIG[0] + ',' + C_ALIG[1] + ',' + C_ALIG[2] + ')', 1.4);
arrow(hero.x, hero.y, view.coh[0], view.coh[1], sc, 'rgb(' + C_COHE[0] + ',' + C_COHE[1] + ',' + C_COHE[2] + ')', 1.4);
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) { // 크기가 바뀌면 위치 배율 보정 후 다시 시드 리셋
prngState = SEED >>> 0;
build();
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다(면적 커버리지)
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph; // 무리마다 회전 속도·위상이 다르다
b.vx += Math.cos(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
b.vy += Math.sin(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) step(1 / 60); // 떼가 자리잡은 뒤의 한 장
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// ThreeRules — vanilla 판(run/315/demo/01-flock-three-rules/index.html) 의 React 포트.
// 보이드 세 규칙(분리·정렬·결합) 스티어링 합산 + 토러스 랩 + 하위 무리 안내로 화면을 넓게 훑는다.
// 대표 개체 1마리에 세 힘 벡터 화살표를 겹쳐 규칙을 눈으로 읽게 한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThreeRules.module.scss";
export type ThreeRulesProps = {
duration?: string;
easing?: string;
color?: string;
boids?: number; // 개체 수 — 변형 "개체 수" 축(260 → 640)
weightSep?: number; // 분리 무게
weightAli?: number; // 정렬 무게
weightCoh?: number; // 결합 무게
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Boid = { x: number; y: number; vx: number; vy: number; ph: number; grp: number };
type Colors = { ink: RGB; mist: RGB; sep: RGB; ali: RGB; coh: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "세 규칙 군집 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 42; // 이웃 반경 (px)
const MAX_SPD = 128; // 최대 속도 (px/s)
const MAX_FOR = 340; // 최대 힘 (px/s^2)
// 시드 고정 PRNG — mulberry32
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 ThreeRules({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
boids = 260,
weightSep = 1.5,
weightAli = 1.0,
weightCoh = 1.0,
}: ThreeRulesProps) {
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 nbRef = useRef(boids) as Box<number>;
const wsRef = useRef(weightSep) as Box<number>;
const waRef = useRef(weightAli) as Box<number>;
const wcRef = useRef(weightCoh) as Box<number>;
const boidsRef = useRef<Boid[]>([]) as Box<Boid[]>;
const viewRef = useRef({ sep: [0, 0], ali: [0, 0], coh: [0, 0] }) as Box<{ sep: number[]; ali: number[]; coh: number[] }>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(boids));
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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 build(): void {
const rand = makePRNG(0x30c101);
const { w: W, h: H } = sizeRef.current;
const arr: Boid[] = [];
for (let i = 0; i < nbRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
ph: rand() * TAU,
grp: i % 4,
});
}
boidsRef.current = arr;
setN(String(nbRef.current));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt: number, tSec: number): void {
const boids = boidsRef.current;
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
b.vy += Math.sin(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const boids = boidsRef.current;
const view = viewRef.current;
const n = boids.length;
for (let i = 0; i < n; i++) {
const b = boids[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = boids[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
const hasSep = sepX !== 0 || sepY !== 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += waRef.current * sA[0] + wcRef.current * sB[0];
fy += waRef.current * sA[1] + wcRef.current * sB[1];
}
if (hasSep) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += wsRef.current * sC[0]; fy += wsRef.current * sC[1];
}
if (i === 0) {
view.sep[0] = hasSep ? wsRef.current * sC[0] : 0;
view.sep[1] = hasSep ? wsRef.current * sC[1] : 0;
view.ali[0] = cnt > 0 ? waRef.current * sA[0] : 0;
view.ali[1] = cnt > 0 ? waRef.current * sA[1] : 0;
view.coh[0] = cnt > 0 ? wcRef.current * sB[0] : 0;
view.coh[1] = cnt > 0 ? wcRef.current * sB[1] : 0;
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;
}
}
function arrow(x: number, y: number, vx: number, vy: number, scale: number, col: string, wdt: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const ex = x + vx * scale, ey = y + vy * scale;
ctx.strokeStyle = col;
ctx.lineWidth = wdt;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex, ey);
ctx.stroke();
const ang = Math.atan2(vy, vx);
const hl = 3.2 + wdt;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang - 0.5) * hl, ey - Math.sin(ang - 0.5) * hl);
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang + 0.5) * hl, ey - Math.sin(ang + 0.5) * hl);
ctx.stroke();
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const boids = boidsRef.current;
if (!ctx || !col || boids.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = `rgb(${col.mist[0]},${col.mist[1]},${col.mist[2]})`;
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
ctx.fillRect(b.x - 1.5, b.y - 2, 3, 4);
}
const hero = boids[0];
if (hero) {
ctx.fillStyle = `rgb(${col.lilac[0]},${col.lilac[1]},${col.lilac[2]})`;
ctx.fillRect(hero.x - 2, hero.y - 3, 4, 6);
const v = viewRef.current;
const sc = 0.24;
arrow(hero.x, hero.y, v.sep[0], v.sep[1], sc, `rgb(${col.sep[0]},${col.sep[1]},${col.sep[2]})`, 1.4);
arrow(hero.x, hero.y, v.ali[0], v.ali[1], sc, `rgb(${col.ali[0]},${col.ali[1]},${col.ali[2]})`, 1.4);
arrow(hero.x, hero.y, v.coh[0], v.coh[1], sc, `rgb(${col.coh[0]},${col.coh[1]},${col.coh[2]})`, 1.4);
}
}
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;
herdGuide(dt, t);
step(dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nbRef.current = boids;
wsRef.current = weightSep;
waRef.current = weightAli;
wcRef.current = weightCoh;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
sep: tokRGB(root, "--flock-sep"),
ali: tokRGB(root, "--flock-ali"),
coh: tokRGB(root, "--flock-coh"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) step(1 / 60);
draw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boids, weightSep, weightAli, weightCoh]);
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="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></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}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개 개체 · 분리·정렬·결합</span>
<span className={styles.metaDim}>캔버스 · 스티어링 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 3規則の群れ — 分離·整列·結合だけで回す最小の群れ — 計測 累積面積 41.242% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
進路への加速を上限で切るステア
// 進行方向へ加速を向けるステア — 目標速度から現在速度を引いて力の上限で切る
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
分離だけは半径の半分に効く
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 分離 — 半径の半分の中でのみ、近いほど強く
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
分離だけが半径の半分 21px に効く非対称が群れの肌理を作り、結合は隣人の重心へ 2.2 倍の目標速度で寄せます。重みは W_SEP 1.5 ・ W_ALI 1.0 ・ W_COH 1.0、速度は MAX_SPD 128px/s を上限にその半分を下限とし、計測は累積面積 41.242% ・強度の平均 124.8 ・動いたフレーム 23/24 です。
02鳥の群れシルエット — 翼の羽ばたき位相がずれる羽毛
130羽が夕暮れの空を横切り、個体ごとにずれた羽ばたきの位相 b.ph が遠目には羽毛の波に見える盤です。

<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="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>夕焼けの空を横切る鳥の群れです — 三角形の翼2枚が個体ごとにずれた位相で羽ばたき、進行方向へ体が傾きます</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="cdN">130</b>羽・羽ばたき位相</span>
<span class="cd__meta cd__meta--dim">ctx.rotate・sin位相・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 새떼 실루엣(02). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(군집 스티어링 + 매 프레임 하늘 그라디언트 재도색 + 날갯짓 sin 위상)가 만든다.
// 무대는 저녁하늘(슬레이트→따뜻한 지평선). 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
$duration: $dur-loop;
$easing: $ease-spring;
$color: $flock-dusk;
@include stage($stage-ink);
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-dusk;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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($flock-mist, .72); }
.cd__val { color: $flock-glow; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #3d4358;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
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 과 같은 뼈대(분리·정렬·결합 + 토러스 랩).
// 장면이 다르다: 배경은 매 프레임 세로 그라디언트(슬레이트→지평선 파스텔)를 다시 칠해 잔상을 남기지 않고,
// 개체는 실루엣 잉크 삼각형 날개 2개 — 날갯짓 위상이 개체마다 어긋나고(sin), 진행 방향으로 ctx.rotate 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 130; // 새 수 — 밀도 축(90 → 260 수준으로 커버리지 확보)
var W_SEP = 1.6; // 분리 무게
var W_ALI = 1.2; // 정렬 무게
var W_COH = 1.0; // 결합 무게
var TONE = 0; // 실루엣 색 변형 — 0: 잉크(#23262f), 1: 라일락(#b9a0ff)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 새떼로 흐른다
var SEED = 0x30c102;
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 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_DUSK = tokRGB('--flock-dusk');
var C_GLOW = tokRGB('--flock-glow');
var C_BIRD = tokRGB('--flock-bird');
var C_LILA = tokRGB('--flock-lilac');
var C_SILH = TONE ? C_LILA : C_BIRD; // 색 변형 — 실루엣 톤 교체
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.6; // reduce 에서 그리는 시각
// ── 시뮬 상수 ──
var R_NEIGH = 52; // 이웃 반경 (px)
var MAX_SPD = 130; // 최대 속도 (px/s)
var MAX_FOR = 260; // 최대 힘 (px/s^2)
// ── 새 개체 ──
var boids = [];
function spawnB(b, i) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.ph = rand() * TAU; // 날갯짓 위상 — 개체마다 어긋나게
b.flap = 5.2 + rand() * 3.4; // 날갯짓 속도 (rad/s)
b.sz = 0.8 + rand() * 0.55; // 날개 스케일
b.grp = (i || 0) % 4; // 하위 무리 — 화면 커버리지
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 180 * dt;
b.vy += Math.sin(ga) * 180 * dt;
}
}
function draw(tSec) {
// 1) 저녁하늘 — 매 프레임 세로 그라디언트를 다시 칠한다(잔상 없음). 지평선 파스텔이 아래.
// 하늘 전체가 은은히 숨쉬게 — 그라디언트 정지점을 tSec 로 미세하게 밀어 넓은 면적이 매 프레임 변한다
var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
var horizon = H * (0.72 + 0.05 * breath);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(' +
(C_DUSK[0] + (C_GLOW[0] - C_DUSK[0]) * 0.35 | 0) + ',' +
(C_DUSK[1] + (C_GLOW[1] - C_DUSK[1]) * 0.35 | 0) + ',' +
(C_DUSK[2] + (C_GLOW[2] - C_DUSK[2]) * 0.35 | 0) + ')');
sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
// 2) 새떼 — 삼각형 날개 2개 실루엣. 진행 방향으로 회전, 날갯짓은 sin 위상
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var flap = Math.sin(tSec * b.flap + b.ph); // -1..1 — 날개 펄럭임
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
var s = b.sz;
// 몸 — 진행축으로 가는 얇은 쐐기
ctx.fillStyle = 'rgb(' + C_SILH[0] + ',' + C_SILH[1] + ',' + C_SILH[2] + ')';
ctx.beginPath();
ctx.moveTo(5 * s, 0);
ctx.lineTo(-3 * s, 1.6 * s);
ctx.lineTo(-3 * s, -1.6 * s);
ctx.closePath();
ctx.fill();
// 날개 앞 — 위상이 양수일 때 위로, 음수일 때 아래로 접힌다
var wy = flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(1.5 * s, 0);
ctx.lineTo(-2.5 * s, (-4.5 + wy) * s * 0 + wy - 4.5 * s);
ctx.lineTo(-1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
// 날개 뒤 — 앞 날개와 반대 위상(어긋난 두 박자)
var wy2 = -flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(-1.5 * s, 0);
ctx.lineTo(2.5 * s, (-4.5 + wy2) * s * 0 + wy2 - 4.5 * s);
ctx.lineTo(1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) {
prngState = SEED >>> 0;
build();
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// BirdSilhouetteWingbeat — vanilla 판(run/315/demo/02-flock-bird-silhouette-wingbeat/index.html) 의 React 포트.
// 군집 코어는 01 과 같은 뼈대(분리·정렬·결합 + 토러스 랩). 장면은 저녁하늘 — 매 프레임 세로 그라디언트를
// 다시 칠해 잔상을 남기지 않고, 개체는 실루엣 삼각형 날개 2개가 개체마다 어긋난 위상으로 파닥인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BirdSilhouetteWingbeat.module.scss";
export type BirdSilhouetteWingbeatProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 새 수 — 밀도 축
tone?: 0 | 1; // 실루엣 색 변형 — 0: 잉크, 1: 라일락
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number; ph: number; flap: number; sz: number; grp: number };
type Colors = { dusk: RGB; glow: RGB; bird: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "새떼 실루엣 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 52; // 이웃 반경 (px)
const MAX_SPD = 130; // 최대 속도 (px/s)
const MAX_FOR = 260; // 최대 힘 (px/s^2)
// 시드 고정 PRNG — mulberry32
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 BirdSilhouetteWingbeat({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#3d4358",
birds = 130,
tone = 0,
}: BirdSilhouetteWingbeatProps) {
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 nbRef = useRef(birds) as Box<number>;
const toneRef = useRef(tone) as Box<number>;
const birdsRef = useRef<Bird[]>([]) as Box<Bird[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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 silhouetteRGB(col: Colors): RGB {
return toneRef.current ? col.lilac : col.bird;
}
function build(): void {
const rand = makePRNG(0x30c102);
const { w: W, h: H } = sizeRef.current;
const arr: Bird[] = [];
for (let i = 0; i < nbRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
ph: rand() * TAU,
flap: 5.2 + rand() * 3.4,
sz: 0.8 + rand() * 0.55,
grp: i % 4,
});
}
birdsRef.current = arr;
setN(String(nbRef.current));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
function herdGuide(dt: number, tSec: number): void {
const birds = birdsRef.current;
for (let i = 0; i < birds.length; i++) {
const b = birds[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 180 * dt;
b.vy += Math.sin(ga) * 180 * dt;
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const birds = birdsRef.current;
const n = birds.length;
for (let i = 0; i < n; i++) {
const b = birds[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = birds[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += 1.2 * sA[0] + 1.0 * sB[0];
fy += 1.2 * sA[1] + 1.0 * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += 1.6 * sC[0]; fy += 1.6 * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birds = birdsRef.current;
if (!ctx || !col || birds.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const silh = silhouetteRGB(col);
const breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
const horizon = H * (0.72 + 0.05 * breath);
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, `rgb(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]})`);
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), `rgb(${
(col.dusk[0] + (col.glow[0] - col.dusk[0]) * 0.35) | 0},${
(col.dusk[1] + (col.glow[1] - col.dusk[1]) * 0.35) | 0},${
(col.dusk[2] + (col.glow[2] - col.dusk[2]) * 0.35) | 0})`);
sky.addColorStop(1, `rgb(${col.glow[0]},${col.glow[1]},${col.glow[2]})`);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < birds.length; i++) {
const b = birds[i];
const flap = Math.sin(tSec * b.flap + b.ph);
const ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
const s = b.sz;
ctx.fillStyle = `rgb(${silh[0]},${silh[1]},${silh[2]})`;
ctx.beginPath();
ctx.moveTo(5 * s, 0);
ctx.lineTo(-3 * s, 1.6 * s);
ctx.lineTo(-3 * s, -1.6 * s);
ctx.closePath();
ctx.fill();
const wy = flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(1.5 * s, 0);
ctx.lineTo(-2.5 * s, wy - 4.5 * s);
ctx.lineTo(-1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
const wy2 = -flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(-1.5 * s, 0);
ctx.lineTo(2.5 * s, wy2 - 4.5 * s);
ctx.lineTo(1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nbRef.current = birds;
toneRef.current = tone;
colRef.current = {
dusk: tokRGB(root, "--flock-dusk"),
glow: tokRGB(root, "--flock-glow"),
bird: tokRGB(root, "--flock-bird"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, tone]);
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="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></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}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 새 · 날갯짓 위상</span>
<span className={styles.metaDim}>ctx.rotate · sin 위상 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · 鳥の群れシルエット — 翼の羽ばたき位相がずれる羽毛 — 計測 累積面積 39.212% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
空は毎フレーム息をする
// 夕暮れの空 — 毎フレーム縦グラデーションを塗り直す。地平線のパステルが下に
var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
var horizon = H * (0.72 + 0.05 * breath);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(' +
(C_DUSK[0] + (C_GLOW[0] - C_DUSK[0]) * 0.35 | 0) + ',' +
(C_DUSK[1] + (C_GLOW[1] - C_DUSK[1]) * 0.35 | 0) + ',' +
(C_DUSK[2] + (C_GLOW[2] - C_DUSK[2]) * 0.35 | 0) + ')');
sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
前後の翼は逆位相で羽ばたく
var flap = Math.sin(tSec * b.flap + b.ph); // -1..1 — 翼の羽ばたき
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
var s = b.sz;
// 前翼 — 位相が正のとき上へ、負のとき下へ折れる
var wy = flap * 4.2 * s;
// 後翼 — 前翼と逆の位相(ずれた二拍子)
var wy2 = -flap * 4.2 * s;
羽ばたきの速さ b.flap は 5.2~8.6 rad/s の個体差で、同じ波でも羽が揃わないのが群れらしさの正体です。TONE を 0 → 1 へ切り替えると墨色 #23262f の羽がライラック #b9a0ff に変わり、計測は累積面積 39.212% ・強度の平均 69.5 ・動いたフレーム 23/24 です。
03魚の群れ — 奥行きのあるスクーリング
140匹が仮想の深さ b.z を持って泳ぎ、遠い個体ほど小さく薄く描かれることで平面のキャンバスに水の層ができる盤です。

<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="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>深海の舞台に浮かぶ魚群です — 個体ごとに仮想の深さz(0〜1)を持ち、遠いほど小さく淡く描かれて層ができ、流線形の体と尾が進行方向へ傾きます</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="cdN">140</b>匹・深さz層</span>
<span class="cd__meta cd__meta--dim">globalAlpha · ellipse · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물고기 떼(03). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(군집 스티어링 + 깊이 z 스케일·globalAlpha 층 + 꼬리 sin)가 만든다.
// 무대는 심해(어스름 잉크 + 상부 옅은 하늘빛 광원). 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-sky: #{$subject-sky}; // 심해 상부 광원·물고기 몸 — 전역 토큰 재사용
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $subject-sky; 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($flock-mist, .72); }
.cd__val { color: $subject-sky; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-sky: #6ec8ff;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #6ec8ff;
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, 220, 230, 0.72);
}
.cd__val {
color: #6ec8ff;
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;
}
}// 물고기 떼(03). 군집 코어는 01 과 같은 뼈대(분리·정렬·결합 + 토러스 랩).
// 장면이 다르다: 심해 무대(잉크 + 상부 옅은 하늘빛 radial 광원) 위에 개체마다 가상 깊이 z(0=가까움, 1=멀음) 을
// 들고 다닌다 — 멀수록 스케일이 작아지고 globalAlpha 가 옅아져 한 화면에 층이 생긴다.
// 몸은 유선형 타원 + 꼬리 삼각형, 진행 방향으로 ctx.rotate. 결합 계수가 뭉침 강도(변형 축).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 140; // 물고기 수
var W_SEP = 1.6; // 분리 무게
var W_ALI = 1.1; // 정렬 무게
var W_COH = 0.6; // 결합 무게 — 변형 "결합 계수" 축(0.6 → 1.2, 뭉침 강도)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 물고기 떼로 흐른다
var SEED = 0x30c103;
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 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('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_SKY = tokRGB('--flock-sky');
var C_LILA = tokRGB('--flock-lilac');
var C_SILH = C_SKY; // 물고기 몸색 — 심해 위 하늘빛 실루엣
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.6; // reduce 에서 그리는 시각
// ── 시뮬 상수 ──
var R_NEIGH = 48; // 이웃 반경 (px)
var MAX_SPD = 100; // 최대 속도 (px/s)
var MAX_FOR = 260; // 최대 힘 (px/s^2)
// ── 물고기 개체 ──
var boids = [];
function spawnB(b, i) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.z = rand(); // 가상 깊이 0(가까움)~1(멀음)
b.ph = rand() * TAU; // 안내 위상
b.sz = 0.75 + rand() * 0.6; // 몸 스케일
b.grp = (i || 0) % 4; // 하위 무리 — 화면 커버리지
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt;
b.vy += Math.sin(ga) * 150 * dt;
}
}
function draw(tSec) {
// 1) 심해 무대 — 잉크 바탕 + 상부 옅은 하늘빛 광원(radial). 빛이 느리게 숨쉬어 넓은 면적이 매 프레임 변한다
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
var lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
var ly = -H * 0.1;
var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
var lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
var la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, 'rgba(' + C_SKY[0] + ',' + C_SKY[1] + ',' + C_SKY[2] + ',' + la.toFixed(3) + ')');
lg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = lg;
ctx.fillRect(0, 0, W, H);
// 2) 물고기 — 깊이 z 순으로 그린다(멀 것부터 → 가까운 것이 위에 덮임)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });
for (var k = 0; k < order.length; k++) {
var b = order[k];
var depth = 1 - b.z; // 1 = 가까움, 0 = 멀음
var sc = (0.45 + 0.75 * depth) * b.sz; // 멀수록 작게
var alpha = 0.35 + 0.6 * depth; // 멀수록 옅게 — 층의 핵심
var ang = Math.atan2(b.vy, b.vx);
// 꼬리 흔들림 — 진행축 수직으로 sin
var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
// 몸 — 유선형 타원(앞이 둥글고 뒤로 갈수록 좁아지는 두 타원 겹침)
ctx.fillStyle = 'rgb(' + C_SILH[0] + ',' + C_SILH[1] + ',' + C_SILH[2] + ')';
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
// 꼬리 — 몸 뒤 삼각형, 수직 sin 흔들림
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
// 3) 가까운 층 강조 — 라일락 포인트 개체 몇 마리(z 가 가장 작은 5마리)에 눈광점
var near = boids.slice().sort(function (p, q) { return p.z - q.z; });
for (var m = 0; m < Math.min(5, near.length); m++) {
var f2 = near[m];
var sc2 = (0.45 + 0.75 * (1 - f2.z)) * f2.sz;
var ang2 = Math.atan2(f2.vy, f2.vx);
ctx.save();
ctx.translate(f2.x, f2.y);
ctx.rotate(ang2);
ctx.fillStyle = 'rgb(' + C_LILA[0] + ',' + C_LILA[1] + ',' + C_LILA[2] + ')';
ctx.beginPath();
ctx.arc(4.2 * sc2, -0.8 * sc2, 0.9 * sc2, 0, TAU);
ctx.fill();
ctx.restore();
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) {
prngState = SEED >>> 0;
build();
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FishSchoolDepth — vanilla 판(run/315/demo/03-flock-fish-school-depth/index.html) 의 React 포트.
// 군집 코어는 01 과 같은 뼈대. 장면은 심해 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게(globalAlpha)
// 그려져 층이 생긴다. 몸은 유선형 타원 + 꼬리 삼각형이 진행 방향으로 눕고 꼬리는 sin 으로 흔들린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FishSchoolDepth.module.scss";
export type FishSchoolDepthProps = {
duration?: string;
easing?: string;
color?: string;
fish?: number; // 물고기 수
cohesion?: number; // 결합 계수 — 변형 "뭉침 강도" 축(0.6 → 1.2)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Fish = { x: number; y: number; vx: number; vy: number; z: number; ph: number; sz: number; grp: number };
type Colors = { ink: RGB; mist: RGB; sky: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "물고기 떼 깊이 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 48; // 이웃 반경 (px)
const MAX_SPD = 100; // 최대 속도 (px/s)
const MAX_FOR = 260; // 최대 힘 (px/s^2)
// 시드 고정 PRNG — mulberry32
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 FishSchoolDepth({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
fish = 140,
cohesion = 0.6,
}: FishSchoolDepthProps) {
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 nfRef = useRef(fish) as Box<number>;
const cohRef = useRef(cohesion) as Box<number>;
const fishRef = useRef<Fish[]>([]) as Box<Fish[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(fish));
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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 build(): void {
const rand = makePRNG(0x30c103);
const { w: W, h: H } = sizeRef.current;
const arr: Fish[] = [];
for (let i = 0; i < nfRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
z: rand(),
ph: rand() * TAU,
sz: 0.75 + rand() * 0.6,
grp: i % 4,
});
}
fishRef.current = arr;
setN(String(nfRef.current));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
function herdGuide(dt: number, tSec: number): void {
const fish = fishRef.current;
for (let i = 0; i < fish.length; i++) {
const b = fish[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt;
b.vy += Math.sin(ga) * 150 * dt;
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const fish = fishRef.current;
const n = fish.length;
for (let i = 0; i < n; i++) {
const b = fish[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = fish[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += 1.1 * sA[0] + cohRef.current * sB[0];
fy += 1.1 * sA[1] + cohRef.current * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += 1.6 * sC[0]; fy += 1.6 * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const fish = fishRef.current;
if (!ctx || !col || fish.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
const lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
const ly = -H * 0.1;
const lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
const lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
const la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, `rgba(${col.sky[0]},${col.sky[1]},${col.sky[2]},${la.toFixed(3)})`);
lg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = lg;
ctx.fillRect(0, 0, W, H);
const order = fish.slice().sort((p, q) => q.z - p.z);
for (let k = 0; k < order.length; k++) {
const b = order[k];
const depth = 1 - b.z;
const sc = (0.45 + 0.75 * depth) * b.sz;
const alpha = 0.35 + 0.6 * depth;
const ang = Math.atan2(b.vy, b.vx);
const tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
ctx.fillStyle = `rgb(${col.sky[0]},${col.sky[1]},${col.sky[2]})`;
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
const near = fish.slice().sort((p, q) => p.z - q.z);
for (let m = 0; m < Math.min(5, near.length); m++) {
const f2 = near[m];
const sc2 = (0.45 + 0.75 * (1 - f2.z)) * f2.sz;
const ang2 = Math.atan2(f2.vy, f2.vx);
ctx.save();
ctx.translate(f2.x, f2.y);
ctx.rotate(ang2);
ctx.fillStyle = `rgb(${col.lilac[0]},${col.lilac[1]},${col.lilac[2]})`;
ctx.beginPath();
ctx.arc(4.2 * sc2, -0.8 * sc2, 0.9 * sc2, 0, TAU);
ctx.fill();
ctx.restore();
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nfRef.current = fish;
cohRef.current = cohesion;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
sky: tokRGB(root, "--flock-sky"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fish, cohesion]);
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="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>심해 무대 위 물고기 떼입니다 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게 그려져 층이 생기고, 유선형 몸과 꼬리가 진행 방향을 따라 눕습니다</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>마리 물고기 · 깊이 z 층</span>
<span className={styles.metaDim}>globalAlpha · ellipse · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 魚の群れ — 奥行きのあるスクーリング — 計測 累積面積 44.171% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
遠い順に並べて手前で上書きする
// 魚 — 深さ z の順に描く(遠いものから → 手前が上に重なる)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });
for (var k = 0; k < order.length; k++) {
var b = order[k];
var depth = 1 - b.z; // 1 = 手前、0 = 奥
var sc = (0.45 + 0.75 * depth) * b.sz; // 遠いほど小さく
var alpha = 0.35 + 0.6 * depth; // 遠いほど薄く — 層の核
var ang = Math.atan2(b.vy, b.vx);
// 尾の揺れ — 進行軸と垂直に sin
var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
手前の層に光を足す
// 上からの光 — 半径も濃さも息をする(0.8 rad/s)
var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
var la = 0.16 + 0.05 * breathe;
// 一番手前の層(z が最も小さい5匹)にライラックのまぶしさを足す
var near = boids.slice().sort(function (p, q) { return p.z - q.z; });
for (var m = 0; m < Math.min(5, near.length); m++) {
var f2 = near[m];
重みは W_SEP 1.6 ・ W_ALI 1.1 ・ W_COH 0.6 で、この結合を 1.2 まで上げる変種が下の表にあります。計測は累積面積 44.171% ・強度の平均 96.1 ・動いたフレーム 23/24 です。
04捕食者の登場 — 逃走ベクトルが群れを割る
160羽の群れに捕食者が入り、逃げる個体だけが急旋回して裂け目が走る様子を FLEE_R 90px の逃げ圏で見せる盤です。

<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="currentColor"><path d="M128 32a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80" opacity=".2"/><path d="M225.86 102.82c-3.77-13.68-11-26.16-21-36.21c-19.55-19.54-45.9-27.06-71.06-21.24a8 8 0 0 0-4.19 13.2l31 34.4l-11.6 11.6l-34.4-31a8 8 0 0 0-13.2 4.19c-5.82 25.16 1.7 51.51 21.24 71.06c14.36 14.36 33.15 22.06 52.4 22.94l-30 30a44 44 0 1 0 11.31 11.31l30-30c.88 19.25 8.58 38 22.94 52.4c19.55 19.54 45.9 27.06 71.06 21.24a8 8 0 0 0 4.19-13.2l-31-34.4l11.6-11.6l34.4 31a8 8 0 0 0 13.2-4.19c5.31-22.96-.44-46.94-16.79-65.5M120 216a28 28 0 1 1-28-28a28 28 0 0 1 28 28"/></g></svg>
<span>ポインターを天敵にします — 半径内の個体は距離に反比例した力で逃げ、群れは分裂してまた集まります。ポインターがなければ、仮想の天敵が自分で巡回します</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="cdN">160</b>体・逃走半径 <b class="cd__val" id="cdR">90</b>px</span>
<span class="cd__meta cd__meta--dim">separation · alignment · cohesion · flee</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 천적 등장(04). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(보이드 3규칙 + 천적 도망 벡터)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(04~06 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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($flock-mist, .72); }
.cd__val { color: $flock-glow; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
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). 보이드 3규칙(분리·정렬·결합)에 네 번째 힘 "도망" 을 더한다.
// 포인터(또는 시드 고정 가상 천적)가 반경 안 개체마다 거리에 반비례하는 회피 벡터를 주입해
// 무리가 순간적으로 갈라졌다가, 천적이 멀어지면 결합력이 다시 봉합한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_BIRD = 160; // 개체 수
var FLEE_R = 90; // 천적 도망 반경(px) — 변형 "천적 반경" 축(90 → 160)
var FLEE_GAIN = 1.0; // 도망 힘 배율 변형
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
var rOut = document.getElementById('cdR');
nOut.textContent = String(N_BIRD);
rOut.textContent = String(FLEE_R);
// 시드 고정 PRNG — mulberry32
var SEED = 0x0b0104;
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('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_GLOW = tokRGB('--flock-glow');
var C_DUSK = tokRGB('--flock-dusk');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var predX = 0.5, predY = 0.5; // 0..1 정규화 좌표
var manual = false;
// ── 보이드 파라미터 ──
var PERCEP = 40; // 이웃 지각 반경
var SEP_R = 18; // 분리 반경
var MAX_SPD = 96; // px/s
var MAX_FRC = 260; // px/s^2
var W_SEP = 1.7, W_ALI = 1.0, W_COH = 0.9;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
birds.push({
x: rand() * 300, y: rand() * 140,
vx: (rand() - 0.5) * MAX_SPD, vy: (rand() - 0.5) * MAX_SPD
});
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function step(dt) {
var predPx = predX * W, predPy = predY * H;
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx = 0, cy = 0;
var sepN = 0, aliN = 0, cohN = 0;
for (var j = 0; j < birds.length; j++) {
if (i === j) continue;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
// 토러스 최단거리(랩된 화면에서 가까운 쪽으로)
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx += dx; cy += dy; cohN++;
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx / cohN) * 0.04 * W_COH; fy += (cy / cohN) * 0.04 * W_COH; }
// ── 도망 — 천적 반경 안이면 거리에 반비례한 힘(가까울수록 급격히 강함) ──
var pdx = b.x - predPx, pdy = b.y - predPy;
if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
var pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
var t = (FLEE_R - pd) / FLEE_R; // 0..1 — 가까울수록 1
var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN;
fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.28) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.28; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.28; }
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function draw(tSec) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 지평선 은은한 슬레이트 띠 — 무대가 완전히 죽지 않게
var hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.22)');
hg.addColorStop(1, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0)');
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
// 개체 — mist 톤 사각 점, 속도 방향으로 살짝 늘여 흐름감
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sp = Math.hypot(b.vx, b.vy);
var a = 0.42 + Math.min(0.5, sp / MAX_SPD * 0.5);
ctx.fillStyle = 'rgba(' + C_MIST[0] + ',' + C_MIST[1] + ',' + C_MIST[2] + ',' + a.toFixed(3) + ')';
var r = 2.1 + Math.min(1.4, sp / MAX_SPD * 1.4);
ctx.fillRect(b.x - r, b.y - r, r * 2, r * 2);
}
// 천적 링 — glow 톤. 숨쉬듯 반경이 진동한다
ctx.globalCompositeOperation = 'source-over';
var ringR = FLEE_R * (0.34 + 0.05 * Math.sin(tSec * 3.1));
var predPx = predX * W, predPy = predY * H;
ctx.strokeStyle = 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.55)';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(predPx, predPy, ringR, 0, TAU);
ctx.stroke();
ctx.fillStyle = 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.85)';
ctx.beginPath();
ctx.arc(predPx, predPy, 3, 0, TAU);
ctx.fill();
}
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;
if (!manual) {
// 가상 천적 — 시드 고정 Lissajous 순회(포인터 없어도 장면이 산다)
var ph = t * TAU / 5.4;
predX = 0.5 + 0.4 * Math.sin(ph);
predY = 0.5 + 0.34 * Math.sin(ph * 1.7 + 1.1);
}
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
predX = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
predY = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { step(0.016); draw(1.2); }
});
field.addEventListener('pointerleave', function () {
if (manual) { predX = -1; predY = -1; } // 화면 밖으로 — 도망 힘 사실상 0
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); predX = 0.5; predY = 0.5; }
predX = Math.max(0, Math.min(1, predX + d[0]));
predY = Math.max(0, Math.min(1, predY + d[1]));
if (reduce) { step(0.016); draw(1.2); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1.2); });
fit();
if (reduce) {
// reduce — 초기 대형이 흩어지기 전, 도망이 시작된 한 장면만 고정해 그린다
predX = 0.5; predY = 0.42;
for (var s = 0; s < 40; s++) step(0.033);
draw(1.2);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// PredatorAttack — vanilla 판(run/315/demo/04-flock-predator-attack/index.html) 의 React 포트.
// 보이드 3규칙(분리·정렬·결합)에 천적 도망 벡터를 더해 무리가 갈라졌다 재봉합한다.
// 미리보기는 시드 고정 Lissajous 가상 천적.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PredatorAttack.module.scss";
export type PredatorAttackProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수
fleeRadius?: number; // 천적 도망 반경(px) — 변형 "천적 반경" 축(90 → 160)
fleeGain?: number; // 도망 힘 배율
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number };
type Colors = { ink: RGB; mist: RGB; glow: RGB; dusk: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "새 떼 천적 회피 실험판. 화살표 키로 천적을 움직일 수 있습니다";
const STILL_T = 1.2;
const PERCEP = 40;
const SEP_R = 18;
const MAX_SPD = 96;
const MAX_FRC = 260;
const W_SEP = 1.7;
const W_ALI = 1.0;
const W_COH = 0.9;
// 시드 고정 PRNG — mulberry32
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 PredatorAttack({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 160,
fleeRadius = 90,
fleeGain = 1.0,
}: PredatorAttackProps) {
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 fleeRRef = useRef(fleeRadius) as Box<number>;
const fleeGRef = useRef(fleeGain) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const predXRef = useRef(0.5) as Box<number>;
const predYRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [rOut, setROut] = useState(String(fleeRadius));
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0104);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
arr.push({
x: rand() * 300,
y: rand() * 140,
vx: (rand() - 0.5) * MAX_SPD,
vy: (rand() - 0.5) * MAX_SPD,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function step(dt: number): void {
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
const predPx = predXRef.current * W;
const predPy = predYRef.current * H;
const FLEE_R = fleeRRef.current;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
for (let j = 0; j < birdsArr.length; j++) {
if (i === j) continue;
const o = birdsArr[j];
let dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.04 * W_COH; fy += (cy0 / cohN) * 0.04 * W_COH; }
// 도망 — 천적 반경 안이면 거리에 반비례한 힘(가까울수록 급격히 강함)
let pdx = b.x - predPx, pdy = b.y - predPy;
if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
const pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
const t = (FLEE_R - pd) / FLEE_R;
const mag = t * t * MAX_FRC * 3.2 * fleeGRef.current;
fx += (pdx / pd) * mag;
fy += (pdy / pd) * mag;
}
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
b.vx += fx * dt;
b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.28) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.28; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.28; }
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.22)`);
hg.addColorStop(1, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0)`);
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const sp = Math.hypot(b.vx, b.vy);
const a = 0.42 + Math.min(0.5, (sp / MAX_SPD) * 0.5);
ctx.fillStyle = `rgba(${col.mist[0]},${col.mist[1]},${col.mist[2]},${a.toFixed(3)})`;
const r = 2.1 + Math.min(1.4, (sp / MAX_SPD) * 1.4);
ctx.fillRect(b.x - r, b.y - r, r * 2, r * 2);
}
ctx.globalCompositeOperation = "source-over";
const ringR = fleeRRef.current * (0.34 + 0.05 * Math.sin(tSec * 3.1));
const predPx = predXRef.current * W;
const predPy = predYRef.current * H;
ctx.strokeStyle = `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.55)`;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(predPx, predPy, ringR, 0, TAU);
ctx.stroke();
ctx.fillStyle = `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.85)`;
ctx.beginPath();
ctx.arc(predPx, predPy, 3, 0, TAU);
ctx.fill();
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
if (!manualRef.current) {
const ph = (t * TAU) / 5.4;
predXRef.current = 0.5 + 0.4 * Math.sin(ph);
predYRef.current = 0.5 + 0.34 * Math.sin(ph * 1.7 + 1.1);
}
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
predXRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
predYRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) { step(0.016); draw(STILL_T); }
}
function onLeave(): void {
if (manualRef.current) { predXRef.current = -1; predYRef.current = -1; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; predXRef.current = 0.5; predYRef.current = 0.5; }
predXRef.current = Math.max(0, Math.min(1, predXRef.current + d[0]));
predYRef.current = Math.max(0, Math.min(1, predYRef.current + d[1]));
if (reduced()) { step(0.016); draw(STILL_T); }
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
fleeRRef.current = fleeRadius;
fleeGRef.current = fleeGain;
setROut(String(fleeRadius));
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
glow: tokRGB(root, "--flock-glow"),
mist: tokRGB(root, "--flock-mist"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
predXRef.current = 0.5;
predYRef.current = 0.42;
for (let s = 0; s < 40; s++) step(0.033);
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, fleeRadius, fleeGain]);
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="currentColor"><path d="M128 32a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80" opacity=".2"/><path d="M225.86 102.82c-3.77-13.68-11-26.16-21-36.21c-19.55-19.54-45.9-27.06-71.06-21.24a8 8 0 0 0-4.19 13.2l31 34.4l-11.6 11.6l-34.4-31a8 8 0 0 0-13.2 4.19c-5.82 25.16 1.7 51.51 21.24 71.06c14.36 14.36 33.15 22.06 52.4 22.94l-30 30a44 44 0 1 0 11.31 11.31l30-30c.88 19.25 8.58 38 22.94 52.4c19.55 19.54 45.9 27.06 71.06 21.24a8 8 0 0 0 4.19-13.2l-31-34.4l11.6-11.6l34.4 31a8 8 0 0 0 13.2-4.19c5.31-22.96-.44-46.94-16.79-65.5M120 216a28 28 0 1 1-28-28a28 28 0 0 1 28 28"/></g></svg>
<span>포인터를 천적으로 — 반경 안 개체는 거리에 반비례한 힘으로 도망치며 무리가 갈라졌다 다시 뭉칩니다. 포인터가 없으면 가상 천적이 스스로 순회합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개체 · 도망 반경 <b className={styles.val}>{rOut}</b>px</span>
<span className={styles.metaDim}>separation · alignment · cohesion · flee</span>
</div>
</div>
</main>
);
}04 · 捕食者の登場 — 逃走ベクトルが群れを割る — 計測 累積面積 40.232% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
トーラスの最短距離で隣人を測る
// トーラスの最短距離(ラップされた画面で近い側へ)
var dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx += dx; cy += dy; cohN++;
}
逃走は距離の二乗で立つ
// 逃走 — 捕食者の半径に入れば、近いほど急に強く押される
var pdx = b.x - predPx, pdy = b.y - predPy;
if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
var pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
var t = (FLEE_R - pd) / FLEE_R; // 0..1 — 近いほど 1
var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN;
fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}
逃げの力は通常の3規則(W_SEP 1.7 ・ W_ALI 1.0 ・ W_COH 0.9)に割り込む4本目で、矢印キーで捕食者を動かせます。計測は累積面積 40.232% ・強度の平均 114.6 ・動いたフレーム 23/24 です。
05千羽の計算 — 空間ハッシュで近傍を探す
全数比較では1000羽で約100万回の距離判定が毎フレーム必要になるのを、48px の升に個体を分けた Map の空間ハッシュで隣接9升だけ調べる盤です。

<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="currentColor"><path d="M40 40h64v64H40z" opacity=".2"/><path d="M96 32H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56H48V48h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56h-40V48h40Zm-72 40H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56H48v-40h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56h-40v-40h40Z"/></g></svg>
<span>画面を48pxのセルに分け、自分のセルと隣接する8マスだけを調べます — 個体が1000匹いても、全数比較(1,000×1,000)なしに群れができます</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="空間ハッシュグリッドBoids実験パネル" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">1000</b>体・フレームごとの近傍探索 <b class="cd__val" id="cdChecks">0</b>回</span>
<span class="cd__meta cd__meta--dim">Mapハッシュ・3x3セル走査・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 천 마리의 셈(05). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(공간 해시 3x3 셀 조사 보이드)가 만든다.
// 셀 경계는 화면에 그리지 않는다(측정 노이즈).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — 04~06 동일) ──
$flock-ink: #1b1e26;
$flock-dusk: #3d4358;
$flock-glow: #f4cfb2;
$flock-bird: #23262f;
$flock-lilac: #b9a0ff;
$flock-mist: #d8dce6;
.cd {
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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($flock-mist, .72); }
.cd__val { color: $flock-glow; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
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). 전수 비교(O(n^2))는 1000개체에서 감당이 안 된다 — 화면을 48px 셀로 나눠
// Map<"cx,cy", 인덱스[]> 로 등록하고, 매 개체는 자기 셀 + 인접 8칸(3x3)만 조사한다.
// 조사 범위가 줄어도 무리 행동(분리·정렬·결합)은 그대로 만들어진다는 것이 이 데모의 요점.
(function () {
// ── 변형 상수 블록 ──
var N_BIRD = 1000; // 개체 수 — 변형 "개체 수" 축(1000 → 2000)
var CELL = 48; // 해시 셀 한 변(px)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
var chOut = document.getElementById('cdChecks');
nOut.textContent = String(N_BIRD);
var SEED = 0x0b0105;
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('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_DUSK = tokRGB('--flock-dusk');
var C_LILAC = tokRGB('--flock-lilac');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
var PERCEP = 30, SEP_R = 12;
var MAX_SPD = 78, MAX_FRC = 220;
var W_SEP = 1.6, W_ALI = 1.0, W_COH = 0.85;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
birds.push({
x: rand() * 400, y: rand() * 180,
vx: (rand() - 0.5) * MAX_SPD, vy: (rand() - 0.5) * MAX_SPD
});
}
// ── 공간 해시 — 매 프레임 다시 짓는다(개체가 매 프레임 옮겨가므로) ──
var grid = new Map();
function cellKey(cx, cy) { return cx + ',' + cy; }
function buildGrid() {
grid.clear();
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
var k = cellKey(cx, cy);
var arr = grid.get(k);
if (!arr) { arr = []; grid.set(k, arr); }
arr.push(i);
}
}
var checksThisFrame = 0;
function step(dt) {
buildGrid();
checksThisFrame = 0;
var predPx = (px01 - 0.5) * 2, predPy = (py01 - 0.5) * 2; // 포인터 -1..1 — 약한 조류(바람) 신호로만 사용
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
var sepN = 0, aliN = 0, cohN = 0;
var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (var oy = -1; oy <= 1; oy++) {
for (var ox = -1; ox <= 1; ox++) {
var arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (var a = 0; a < arr.length; a++) {
var j = arr[a];
if (j === i) continue;
checksThisFrame++;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.05 * W_COH; fy += (cy0 / cohN) * 0.05 * W_COH; }
// 약한 조류(포인터/가상) — 무리 전체가 한쪽으로 서서히 쏠리는 신호만
fx += predPx * 8; fy += predPy * 8;
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.3; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.3; }
b.x += b.vx * dt; b.y += b.vy * dt;
}
// 위치 갱신 뒤 랩(다음 buildGrid 가 이 값으로 다시 짓는다)
for (var w = 0; w < birds.length; w++) {
var bb = birds[w];
if (bb.x < 0) bb.x += W; else if (bb.x > W) bb.x -= W;
if (bb.y < 0) bb.y += H; else if (bb.y > H) bb.y -= H;
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function draw(tSec) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.2)');
hg.addColorStop(1, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0)');
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sp = Math.hypot(b.vx, b.vy);
var flash = (i % 7 === (((tSec * 6) | 0) % 7)) ? 0.22 : 0; // 지역 밀도 대신 순환 반짝임 — 정적 카드가 아니게
var a = 0.34 + Math.min(0.4, sp / MAX_SPD * 0.4) + flash;
var col = (i % 23 === 0) ? C_LILAC : C_MIST;
ctx.fillStyle = 'rgba(' + col[0] + ',' + col[1] + ',' + col[2] + ',' + a.toFixed(3) + ')';
ctx.fillRect(b.x - 1, b.y - 1, 2, 2);
}
chOut.textContent = String(checksThisFrame);
}
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;
if (!manual) {
var ph = t * TAU / 6.2;
px01 = 0.5 + 0.4 * Math.sin(ph);
py01 = 0.5 + 0.32 * Math.sin(ph * 1.4 + 0.6);
}
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { step(0.016); draw(1.2); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) { step(0.016); draw(1.2); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1.2); });
fit();
if (reduce) {
for (var s = 0; s < 30; s++) step(0.033);
draw(1.2);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SpatialHashGrid — vanilla 판(run/315/demo/05-flock-spatial-hash-grid/index.html) 의 React 포트.
// 화면을 48px 셀로 나눠 Map<"cx,cy", 인덱스[]> 로 등록하고 자기+인접 8칸만 조사한다 —
// 1000개체를 전수 비교 없이 무리로 만든다. 미리보기는 시드 고정 Lissajous 조류 신호.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpatialHashGrid.module.scss";
export type SpatialHashGridProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수 — 변형 "개체 수" 축(1000 → 2000)
cell?: number; // 해시 셀 한 변(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number };
type Colors = { ink: RGB; mist: RGB; dusk: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "공간 해시 그리드 보이드 실험판";
const STILL_T = 1.2;
const PERCEP = 30;
const SEP_R = 12;
const MAX_SPD = 78;
const MAX_FRC = 220;
const W_SEP = 1.6;
const W_ALI = 1.0;
const W_COH = 0.85;
// 시드 고정 PRNG — mulberry32
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 SpatialHashGrid({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 1000,
cell = 48,
}: SpatialHashGridProps) {
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 cellRef = useRef(cell) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const gridRef = useRef(new Map<string, number[]>()) as Box<Map<string, number[]>>;
const checksRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [checks, setChecks] = 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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0105);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
arr.push({
x: rand() * 400,
y: rand() * 180,
vx: (rand() - 0.5) * MAX_SPD,
vy: (rand() - 0.5) * MAX_SPD,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function cellKey(cx: number, cy: number): string {
return cx + "," + cy;
}
// ── 공간 해시 — 매 프레임 다시 짓는다(개체가 매 프레임 옮겨가므로) ──
function buildGrid(): void {
const grid = gridRef.current;
const birdsArr = birdsArrRef.current;
const CELL = cellRef.current;
grid.clear();
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const cx = Math.floor(b.x / CELL);
const cy = Math.floor(b.y / CELL);
const k = cellKey(cx, cy);
const arr = grid.get(k);
if (arr) arr.push(i);
else grid.set(k, [i]);
}
}
function step(dt: number): void {
buildGrid();
const grid = gridRef.current;
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
const CELL = cellRef.current;
let checks = 0;
const driftX = (pxRef.current - 0.5) * 2;
const driftY = (pyRef.current - 0.5) * 2;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
const gcx = Math.floor(b.x / CELL);
const gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
const arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (let a = 0; a < arr.length; a++) {
const j = arr[a];
if (j === i) continue;
checks++;
const o = birdsArr[j];
const dx = o.x - b.x;
const dy = o.y - b.y;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.05 * W_COH; fy += (cy0 / cohN) * 0.05 * W_COH; }
fx += driftX * 8;
fy += driftY * 8;
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
b.vx += fx * dt;
b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.3; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.3; }
b.x += b.vx * dt;
b.y += b.vy * dt;
}
for (let w = 0; w < birdsArr.length; w++) {
const bb = birdsArr[w];
if (bb.x < 0) bb.x += W; else if (bb.x > W) bb.x -= W;
if (bb.y < 0) bb.y += H; else if (bb.y > H) bb.y -= H;
}
checksRef.current = checks;
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.2)`);
hg.addColorStop(1, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0)`);
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = "lighter";
const slot = ((tSec * 6) | 0) % 7;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const sp = Math.hypot(b.vx, b.vy);
const flash = i % 7 === slot ? 0.22 : 0;
const a = 0.34 + Math.min(0.4, (sp / MAX_SPD) * 0.4) + flash;
const col2 = i % 23 === 0 ? col.lilac : col.mist;
ctx.fillStyle = `rgba(${col2[0]},${col2[1]},${col2[2]},${a.toFixed(3)})`;
ctx.fillRect(b.x - 1, b.y - 1, 2, 2);
}
setChecks(String(checksRef.current));
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
if (!manualRef.current) {
const ph = (t * TAU) / 6.2;
pxRef.current = 0.5 + 0.4 * Math.sin(ph);
pyRef.current = 0.5 + 0.32 * Math.sin(ph * 1.4 + 0.6);
}
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) { step(0.016); draw(STILL_T); }
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) { step(0.016); draw(STILL_T); }
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
cellRef.current = cell;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
lilac: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let s = 0; s < 30; s++) step(0.033);
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, cell]);
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="currentColor"><path d="M40 40h64v64H40z" opacity=".2"/><path d="M96 32H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56H48V48h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56h-40V48h40Zm-72 40H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56H48v-40h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56h-40v-40h40Z"/></g></svg>
<span>화면을 48px 셀로 나눠 자기 셀과 인접 8칸만 조사합니다 — 개체 1000마리라도 전수 비교(1,000×1,000) 없이 무리가 만들어집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개체 · 프레임당 이웃 조사 <b className={styles.val}>{checks}</b>회</span>
<span className={styles.metaDim}>Map 해시 · 3x3 셀 조사 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 · 千羽の計算 — 空間ハッシュで近傍を探す — 計測 累積面積 48.21% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
毎フレーム建て直す升と鍵
// 空間ハッシュ — 個体が毎フレーム移るので毎フレーム建て直す
var grid = new Map();
function cellKey(cx, cy) { return cx + ',' + cy; }
function buildGrid() {
grid.clear();
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
var k = cellKey(cx, cy);
var arr = grid.get(k);
if (!arr) { arr = []; grid.set(k, arr); }
arr.push(i);
}
}
自分の升と隣の8升だけを見る
var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// 自分の升 + 隣接8升(3x3)だけ調べる — この盤の心臓部
for (var oy = -1; oy <= 1; oy++) {
for (var ox = -1; ox <= 1; ox++) {
var arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (var a = 0; a < arr.length; a++) {
var j = arr[a];
if (j === i) continue;
checksThisFrame++;
バーの checksThisFrame に判定回数が生で出るので、N_BIRD を 1000 → 2000 へ倍にしても増え方が線形にとどまる様子が読めます。計測は累積面積 48.21% ・強度の平均 71.3 ・動いたフレーム 23/24 です。
06マミュレーション — 流線を残す黒い雲
400羽が黒い雲の形に呼吸し、残像 TRAIL 0.14 の覆いの上に風で押された軌跡が流線として積もる盤です。

<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="currentColor"><path d="M24 132c22-6 38 6 56 4s34-12 52-8 36 16 54 12 34-14 46-18l-2-10c-14 4-32 16-46 20s-34-8-52-12-36 8-54 10-34-10-52-4Z" opacity=".25"/><path d="M230.53 28.51a8 8 0 0 0-11.31 0L196.3 51.43A108 108 0 0 0 45.14 196.06l-22.8 22.79a8 8 0 1 0 11.31 11.31l23.24-23.23A108 108 0 0 0 202 60.31l23.17-23.18a8 8 0 0 0 5.36-8.62M38 128a90 90 0 0 1 148-68.66L59.34 186A89.75 89.75 0 0 1 38 128m90 90a89.75 89.75 0 0 1-58-21.06L196.66 70A90 90 0 0 1 128 218"/></g></svg>
<span>毎フレーム背景を半透明で重ね、個体の軌跡が流線として残ります — 純粋なサイン重ね合わせのカールノイズ風が400体の黒い波を押し流します</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="cdN">400</b>体・残像0.14オーバーレイ</span>
<span class="cd__meta cd__meta--dim">カールノイズ・トレイル蓄積・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 머뮤레이션(06). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(잔상 트레일 + 컬 노이즈 바람 보이드)가 만든다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — 04~06 동일) ──
$flock-ink: #1b1e26;
$flock-dusk: #3d4358;
$flock-glow: #f4cfb2;
$flock-bird: #23262f;
$flock-lilac: #b9a0ff;
$flock-mist: #d8dce6;
.cd {
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
cursor: default; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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($flock-mist, .72); }
.cd__val { color: $flock-glow; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
cursor: default;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
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;
}
}// 머뮤레이션(06). 잔상 트레일 — 매 프레임 배경을 rgba(ink, 0.14) 로 덮어 이전 프레임이
// 서서히 지워지며 개체 궤적이 흐름선으로 남는다(첫 프레임은 불투명 full).
// 바람은 2D potential(순수 사인 중첩)을 만들어 그 회전(컬)을 샘플링한다 — 라이브러리 0.
(function () {
// ── 변형 상수 블록 ──
var N_BIRD = 400; // 개체 수
var TRAIL = 0.14; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
var WIND = 1.0; // 바람 세기 배율 — 변형 "바람 세기" 축(1.0 → 2.0)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
nOut.textContent = String(N_BIRD);
var SEED = 0x0b0106;
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('--flock-ink');
var C_GLOW = tokRGB('--flock-glow');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var firstFrame = true;
var PERCEP = 26, SEP_R = 11;
var MAX_SPD = 88, MAX_FRC = 240;
var W_SEP = 1.5, W_ALI = 1.0, W_COH = 0.8;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
var an = rand() * TAU;
birds.push({
x: rand() * 400, y: rand() * 180,
vx: Math.cos(an) * MAX_SPD * 0.6, vy: Math.sin(an) * MAX_SPD * 0.6,
sz: 1.5 + rand() * 1.0
});
}
// ── 컬 노이즈 바람 — potential psi 의 회전(u, v) = (dpsi/dy, -dpsi/dx) ──
// psi 는 사인 3겹 중첩(방향·주파수 다른 3개) — 라이브러리 없이 계산으로 만드는 흐름장.
function windAt(x, y, tSec) {
var s = 0.004; // 공간 주파수 기준
var psi =
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45;
var e = 1.5; // 유한차분 간격(px)
var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
// 회전장 — 발산 0(공기가 쌓이거나 빠지지 않는다)
var mag = 46 * WIND;
return [dpsiDy * mag, -dpsiDx * mag];
}
function psiAt(x, y, tSec) {
var s = 0.004;
return (
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45
);
}
function step(dt, tSec) {
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
var sepN = 0, aliN = 0, cohN = 0;
for (var j = 0; j < birds.length; j++) {
if (i === j) continue;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.045 * W_COH; fy += (cy0 / cohN) * 0.045 * W_COH; }
// 컬 노이즈 바람 — 무리 전체에 검은 물결을 만드는 주 동력
var wv = windAt(b.x, b.y, tSec);
fx += wv[0]; fy += wv[1];
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.3; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.3; }
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
function draw(dt, tSec) {
// 1) 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 0.14 로 궤적을 남긴다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
firstFrame = false;
// 2) 지평선 따뜻한 띠 — 매 프레임 얇게 다시 그려 잔상 위에 산다
var hy = H * 0.82;
var hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
hb.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
hb.addColorStop(0.5, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.5)');
hb.addColorStop(1, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
ctx.fillStyle = hb;
ctx.fillRect(0, hy - 10, W, 18);
// 3) 개체 — 실루엣 잉크 점. 배경보다 살짝 밝은 먹(잔상 잉크와 대비 유지)
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
ctx.fillStyle = 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.30)';
ctx.fillRect(b.x - b.sz, b.y - b.sz, b.sz * 2, b.sz * 2);
}
}
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(dt, t);
draw(dt, t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(0, 1.2); });
fit();
if (reduce) {
// reduce — 바람이 궤적을 그린 뒤의 한 장만 고정해 그린다
for (var s = 0; s < 60; s++) step(0.033, 1.0 + s * 0.033);
firstFrame = true;
draw(0, 3.0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MurmurationFlowline — vanilla 판(run/315/demo/06-flock-murmuration-flowline/index.html) 의 React 포트.
// 매 프레임 배경을 반투명(0.14)으로 덮어 개체 궤적이 흐름선으로 남고,
// 순수 사인 중첩 컬 노이즈 바람이 400개체의 검은 물결을 몰아간다(라이브러리 0).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MurmurationFlowline.module.scss";
export type MurmurationFlowlineProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수
trail?: number; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
wind?: number; // 바람 세기 배율 — 변형 "바람 세기" 축(1.0 → 2.0)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number; sz: number };
type Colors = { ink: RGB; glow: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "머뮤레이션 잔상 실험판";
const STILL_T = 3.0;
const PERCEP = 26;
const SEP_R = 11;
const MAX_SPD = 88;
const MAX_FRC = 240;
const W_SEP = 1.5;
const W_ALI = 1.0;
const W_COH = 0.8;
// 시드 고정 PRNG — mulberry32
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;
};
}
// 2D potential — 사인 3겹 중첩(방향·주파수 다른 3개). 라이브러리 없이 계산으로 만드는 흐름장
function psiAt(x: number, y: number, tSec: number): number {
const s = 0.004;
return (
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45
);
}
export default function MurmurationFlowline({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 400,
trail = 0.14,
wind = 1.0,
}: MurmurationFlowlineProps) {
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 trailRef = useRef(trail) as Box<number>;
const windRef = useRef(wind) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const firstRef = useRef(true) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0106);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
const an = rand() * TAU;
arr.push({
x: rand() * 400,
y: rand() * 180,
vx: Math.cos(an) * MAX_SPD * 0.6,
vy: Math.sin(an) * MAX_SPD * 0.6,
sz: 1.5 + rand() * 1.0,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
firstRef.current = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
// ── 컬 노이즈 바람 — potential psi 의 회전(u, v) = (dpsi/dy, -dpsi/dx). 발산 0 ──
function windAt(x: number, y: number, tSec: number): [number, number] {
const e = 1.5;
const dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
const dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
const mag = 46 * windRef.current;
return [dpsiDy * mag, -dpsiDx * mag];
}
function step(dt: number, tSec: number): void {
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
for (let j = 0; j < birdsArr.length; j++) {
if (i === j) continue;
const o = birdsArr[j];
let dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.045 * W_COH; fy += (cy0 / cohN) * 0.045 * W_COH; }
const wv = windAt(b.x, b.y, tSec);
fx += wv[0];
fy += wv[1];
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
b.vx += fx * dt;
b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.3; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.3; }
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
// 1) 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 0.14 로 궤적을 남긴다
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},${firstRef.current ? 1 : trailRef.current})`;
ctx.fillRect(0, 0, W, H);
firstRef.current = false;
// 2) 지평선 따뜻한 띠 — 매 프레임 얇게 다시 그려 잔상 위에 산다
const hy = H * 0.82;
const hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
hb.addColorStop(0, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0)`);
hb.addColorStop(0.5, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.5)`);
hb.addColorStop(1, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0)`);
ctx.fillStyle = hb;
ctx.fillRect(0, hy - 10, W, 18);
// 3) 개체 — 지평선 톤 점(잔상 잉크 위 대비 유지)
ctx.fillStyle = `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.30)`;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
ctx.fillRect(b.x - b.sz, b.y - b.sz, b.sz * 2, b.sz * 2);
}
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
step(dt, t);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
trailRef.current = trail;
windRef.current = wind;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
glow: tokRGB(root, "--flock-glow"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) draw();
}
window.addEventListener("resize", onResize);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
// reduce — 바람이 궤적을 그린 뒤의 한 장만 고정해 그린다
for (let s = 0; s < 60; s++) step(0.033, 1.0 + s * 0.033);
firstRef.current = true;
draw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, trail, wind]);
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="currentColor"><path d="M24 132c22-6 38 6 56 4s34-12 52-8 36 16 54 12 34-14 46-18l-2-10c-14 4-32 16-46 20s-34-8-52-12-36 8-54 10-34-10-52-4Z" opacity=".25"/><path d="M230.53 28.51a8 8 0 0 0-11.31 0L196.3 51.43A108 108 0 0 0 45.14 196.06l-22.8 22.79a8 8 0 1 0 11.31 11.31l23.24-23.23A108 108 0 0 0 202 60.31l23.17-23.18a8 8 0 0 0 5.36-8.62M38 128a90 90 0 0 1 148-68.66L59.34 186A89.75 89.75 0 0 1 38 128m90 90a89.75 89.75 0 0 1-58-21.06L196.66 70A90 90 0 0 1 128 218"/></g></svg>
<span>매 프레임 배경을 반투명하게 덮어 개체 궤적이 흐름선으로 남습니다 — 순수 사인 중첩 컬 노이즈 바람이 400개체의 검은 물결을 몰아갑니다</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>개체 · 잔상 0.14 덮개</span>
<span className={styles.metaDim}>curl noise · 트레일 누적 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · マミュレーション — 流線を残す黒い雲 — 計測 累積面積 51.635% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
発散ゼロの回転風を作る
// カールノイズの風 — ポテンシャル psi の回転 (u, v) = (dpsi/dy, -dpsi/dx)
// psi は正弦波3層の重ね合わせ(方向も周波数も違う3つ) — ライブラリなしで作る流れ場
function windAt(x, y, tSec) {
var s = 0.004; // 空間周波数の基準
var e = 1.5; // 有限差分の間隔(px)
var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
var mag = 46 * WIND; // 回転場 — 発散 0(空気が溜まらず抜けもしない)
return [dpsiDy * mag, -dpsiDx * mag];
}
残像の上に毎フレーム薄く巻く
// 残像の覆い — 初フレームは不透明、以降は半透明 0.14 で軌跡を残す
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
// 地平線の暖かい帯 — 毎フレーム薄く描き直して残像の上に乗る
var hy = H * 0.82;
var hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
3規則は W_SEP 1.5 ・ W_ALI 1.0 ・ W_COH 0.8 と控えめで、WIND を 1.0 → 2.0 へ上げると雲の渦が深まります。9枚の中で最も高い累積面積 51.635% ・強度の平均 58.2 ・動いたフレーム 23/24 を記録した盤です。
07餌を中心に — 引力の周りを回る軌道
220個の個体が餌を中心とした引力と接線の渦で回り続け、個体ごとの目標半径 270 × orbBias が画面全域に同心の軌道を広げる盤です。

<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"><ellipse cx="128" cy="128" rx="96" ry="40"/><ellipse cx="128" cy="128" rx="40" ry="96" transform="rotate(45 128 128)"/></g><circle cx="128" cy="128" r="20" fill="currentColor"/></svg>
<span>中央の引力点が110匹をゆっくりとした渦に引き込みます — ポインターxは回転、yは引力の強さです</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="cdN">110</b>匹・引力渦の軌道</span>
<span class="cd__meta cd__meta--dim">createRadialGradient・近傍3ベクトル・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 궤도를 도는 무리(07). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(인력점 소용돌이 + 군집 3벡터)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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($flock-mist, .72); }
.cd__val { color: $flock-glow; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
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). 캔버스 중앙에 고정된 인력점 하나가 개체 110마리를 붙잡아
// 방사형 감쇠 인력 + 접선(소용돌이) 힘으로 느린 궤도를 돌게 한다. 그 위에 표준 군집
// 3벡터(분리·정렬·결합)를 약하게 얹어 개체가 서로 부딪히지 않고 무리 결을 유지한다.
// 포인터 x 는 소용돌이 회전 배율, y 는 인력 세기 배율 — 인력점 위치 자체는 항상 중앙 고정.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 220; // 개체 수 — 화면을 더 넓게 훑도록 110→220
var MAX_SPEED = 1.6; // 최대 속도(정규화) — 0.9→1.6, 넓은 궤도를 더 활발히 돈다
var ATTRACT = 26; // 인력 세기 — 변형 "인력 세기" 축(16 → 40). 낮게 잡아 궤도가 넓게 퍼진다
var SWIRL = 40; // 소용돌이(접선) 힘
var TRAIL = 0.08; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남아 누적면적이 커진다
// ────────────────────────────────────────────────────
var SPEED_UNIT = 140; // px/s 환산 — MAX_SPEED 를 실제 속도로
var MAXSPD_PXS = MAX_SPEED * SPEED_UNIT;
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x07f10c1;
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 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('--flock-ink');
var C_DUSK = tokRGB('--flock-dusk');
var C_GLOW = tokRGB('--flock-glow');
var C_BIRD = tokRGB('--flock-bird');
var C_LILAC = tokRGB('--flock-lilac');
var C_MIST = tokRGB('--flock-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
var firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
// ── 이웃 반경 안 3벡터 파라미터(군집 코어 공통 뼈대) ──
var NEI_R = 26, NEI_R2 = NEI_R * NEI_R, SEP_R = 11;
var W_SEP = 20, W_ALI = 7, W_COH = 4.2;
var boids = [];
function spawn(b) {
var ang = rand() * TAU;
var rad = 30 + rand() * 260; // 넓게 흩어진 시작 — 안쪽 띠부터 화면 가장자리 궤도까지
b.x = rad * Math.cos(ang);
b.y = rad * Math.sin(ang);
b.vx = (rand() - 0.5) * 20;
b.vy = (rand() - 0.5) * 20;
b.orbBias = 0.55 + rand() * 1.65; // 개체마다 다른 목표 궤도 반경(0.55~2.2배) — 화면 전역을 훑는다
}
for (var i = 0; i < N; i++) { var b = {}; spawn(b); boids.push(b); }
// 표준 군집 3벡터 — 토러스 최단거리 보정, 분리·정렬·결합
function flockForce(idx, cx0, cy0) {
var p = boids[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (var j = 0; j < N; j++) {
if (j === idx) continue;
var q = boids[j];
var dx = q.x - p.x, dy = q.y - p.y;
if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.2;
function draw(tSec, dt) {
// 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 TRAIL 로 궤적을 남겨 누적면적을 넓힌다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
firstFrame = false;
var cx = W * 0.5, cy = H * 0.5;
var spinMul = 0.5 + px01 * 1.1; // 포인터 x — 소용돌이 배율
var pullMul = 0.5 + py01 * 1.1; // 포인터 y — 인력 세기 배율
// ── 배경 슬레이트 층 — 완만한 명도 파동으로 화면 넓은 영역이 실제로 변한다 ──
var skyA = 0.16 + 0.08 * Math.sin(tSec * 0.7);
ctx.fillStyle = 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',' + skyA.toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
// ── 인력점 미광 링 — breathe 로 반경·알파가 매 프레임 재계산된다 ──
var breathe = 0.86 + 0.14 * Math.sin(tSec * 1.6);
var R0 = Math.min(W, H) * 0.10 * breathe;
var RMAX = Math.min(W, H) * 0.46 * breathe;
var A0 = 0.62 * (0.7 + 0.3 * Math.sin(tSec * 2.1 + 0.4)) * (0.5 + 0.5 * pullMul);
ctx.globalCompositeOperation = 'lighter';
var LAYERS = 8;
for (var L = 0; L < LAYERS; L++) {
var Lt = L / (LAYERS - 1);
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.8);
var a = Math.max(0.02, A0 * Math.pow(R0 / R, 0.9));
var rg = ctx.createRadialGradient(cx, cy, R * 0.05, cx, cy, R);
rg.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',' + a.toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
// ── 궤도 안내 원 — 얇은 결(장식, 시청 방향 유도) ──
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.35)';
ctx.lineWidth = 1;
for (var ring = 1; ring <= 3; ring++) {
ctx.beginPath();
ctx.ellipse(cx, cy, 26 * ring, 26 * ring * 0.62, 0, 0, TAU);
ctx.stroke();
}
// ── 개체 갱신·묘화 ──
ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
var p = boids[i2];
var dx = p.x, dy = p.y;
var d = Math.hypot(dx, dy) || 0.0001;
var nx = dx / d, ny = dy / d;
var orbR = 270 * p.orbBias; // 개체별 목표 궤도 반경 — 화면 전역에 궤도가 퍼진다
var decay = Math.exp(-(d * d) / (orbR * orbR)); // 가우시안 감쇠 — 중심 근처만 강히 당기고 바깥은 자유 궤도
var pull = -ATTRACT * pullMul * decay;
var tx = -ny, ty = nx;
var swirlR = 300 * p.orbBias;
var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
var ff = flockForce(i2, cx, cy);
var ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
if (sp > MAXSPD_PXS) { p.vx = p.vx / sp * MAXSPD_PXS; p.vy = p.vy / sp * MAXSPD_PXS; }
p.x += p.vx * dt; p.y += p.vy * dt;
// 토러스 랩 — 화면 밖으로 크게 벗어나면 반대편으로
if (p.x > cx) p.x -= cx * 2; else if (p.x < -cx) p.x += cx * 2;
if (p.y > cy) p.y -= cy * 2; else if (p.y < -cy) p.y += cy * 2;
var fx2 = cx + p.x, fy2 = cy + p.y;
var mix = Math.min(1, d / 120);
var rC = C_GLOW[0] + (C_LILAC[0] - C_GLOW[0]) * mix;
var gC = C_GLOW[1] + (C_LILAC[1] - C_GLOW[1]) * mix;
var bC = C_GLOW[2] + (C_LILAC[2] - C_GLOW[2]) * mix;
var rad = 2.1 + 1.1 * (1 - mix);
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',0.55)';
ctx.fillRect(fx2 - rad, fy2 - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_MIST[0] + ',' + C_MIST[1] + ',' + C_MIST[2] + ',0.9)';
ctx.beginPath();
ctx.arc(cx, cy, 2.4, 0, TAU);
ctx.fill();
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
nOut.textContent = String(N);
firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
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;
if (!manual) {
var ph = t * TAU / 5.4;
px01 = 0.5 + 0.36 * Math.sin(ph + 0.3);
py01 = 0.5 + 0.30 * Math.sin(1.7 * ph + 1.1);
}
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// AttractorOrbit — vanilla 판(run/315/demo/07-flock-attractor-orbit/index.html) 의 React 포트.
// 캔버스 중앙 인력점 하나가 개체들을 방사형 감쇠 인력 + 접선(소용돌이) 힘으로 붙잡아
// 느린 궤도를 돌게 한다. 개체마다 다른 목표 궤도 반경(orbBias)과 잔상 트레일로 화면 넓은
// 영역을 훑는다. 그 위에 표준 군집 3벡터(분리·정렬·결합)를 약하게 얹는다(라이브러리 0).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AttractorOrbit.module.scss";
export type AttractorOrbitProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수
maxSpeed?: number; // 최대 속도(정규화)
attract?: number; // 인력 세기 — 변형 "인력 세기" 축(16 → 40)
swirl?: number; // 소용돌이(접선) 힘
trail?: number; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Boid = { x: number; y: number; vx: number; vy: number; orbBias: number };
type Colors = { ink: RGB; dusk: RGB; glow: RGB; lilac: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "궤도 소용돌이 실험판";
const STILL_T = 1.2;
const SPEED_UNIT = 140;
const NEI_R = 26, NEI_R2 = NEI_R * NEI_R, SEP_R = 11;
const W_SEP = 20, W_ALI = 7, W_COH = 4.2;
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
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 AttractorOrbit({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
count = 220,
maxSpeed = 1.6,
attract = 26,
swirl = 40,
trail = 0.08,
}: AttractorOrbitProps) {
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 boidsRef = useRef<Boid[]>([]) as Box<Boid[]>;
const firstRef = useRef(true) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
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 spawn(rand: () => number): Boid {
const ang = rand() * TAU;
const rad = 30 + rand() * 260;
return {
x: rad * Math.cos(ang),
y: rad * Math.sin(ang),
vx: (rand() - 0.5) * 20,
vy: (rand() - 0.5) * 20,
orbBias: 0.55 + rand() * 1.65,
};
}
function buildBoids(): void {
const rand = makePRNG(0x07f10c1);
const arr: Boid[] = [];
for (let i = 0; i < count; i++) arr.push(spawn(rand));
boidsRef.current = arr;
setN(String(count));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
firstRef.current = true;
}
function flockForce(idx: number, cx0: number, cy0: number): [number, number] {
const boids = boidsRef.current;
const p = boids[idx];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < boids.length; j++) {
if (j === idx) continue;
const q = boids[j];
let dx = q.x - p.x, dy = q.y - p.y;
if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const boids = boidsRef.current;
if (!ctx || !col || boids.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const maxSpdPxs = maxSpeed * SPEED_UNIT;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},${firstRef.current ? 1 : trail})`;
ctx.fillRect(0, 0, W, H);
firstRef.current = false;
const cx = W * 0.5, cy = H * 0.5;
const spinMul = 0.5 + pxRef.current * 1.1;
const pullMul = 0.5 + pyRef.current * 1.1;
const skyA = 0.16 + 0.08 * Math.sin(tSec * 0.7);
ctx.fillStyle = `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},${skyA.toFixed(3)})`;
ctx.fillRect(0, 0, W, H);
const breathe = 0.86 + 0.14 * Math.sin(tSec * 1.6);
const R0 = Math.min(W, H) * 0.1 * breathe;
const RMAX = Math.min(W, H) * 0.46 * breathe;
const A0 = 0.62 * (0.7 + 0.3 * Math.sin(tSec * 2.1 + 0.4)) * (0.5 + 0.5 * pullMul);
ctx.globalCompositeOperation = "lighter";
const LAYERS = 8;
for (let L = 0; L < LAYERS; L++) {
const Lt = L / (LAYERS - 1);
const R = R0 + (RMAX - R0) * Math.pow(Lt, 0.8);
const a = Math.max(0.02, A0 * Math.pow(R0 / R, 0.9));
const rg = ctx.createRadialGradient(cx, cy, R * 0.05, cx, cy, R);
rg.addColorStop(0, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},${a.toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.35)`;
ctx.lineWidth = 1;
for (let ring = 1; ring <= 3; ring++) {
ctx.beginPath();
ctx.ellipse(cx, cy, 26 * ring, 26 * ring * 0.62, 0, 0, TAU);
ctx.stroke();
}
ctx.globalCompositeOperation = "lighter";
for (let i2 = 0; i2 < boids.length; i2++) {
const p = boids[i2];
const dx = p.x, dy = p.y;
const d = Math.hypot(dx, dy) || 0.0001;
const nx = dx / d, ny = dy / d;
const orbR = 270 * p.orbBias;
const decay = Math.exp(-(d * d) / (orbR * orbR));
const pull = -attract * pullMul * decay;
const tx = -ny, ty = nx;
const swirlR = 300 * p.orbBias;
const sw = swirl * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
const ff = flockForce(i2, cx, cy);
const ax = nx * pull + tx * sw + ff[0];
const ay = ny * pull + ty * sw + ff[1];
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
if (sp > maxSpdPxs) { p.vx = (p.vx / sp) * maxSpdPxs; p.vy = (p.vy / sp) * maxSpdPxs; }
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.x > cx) p.x -= cx * 2; else if (p.x < -cx) p.x += cx * 2;
if (p.y > cy) p.y -= cy * 2; else if (p.y < -cy) p.y += cy * 2;
const fx2 = cx + p.x, fy2 = cy + p.y;
const mix = Math.min(1, d / 120);
const rC = col.glow[0] + (col.lilac[0] - col.glow[0]) * mix;
const gC = col.glow[1] + (col.lilac[1] - col.glow[1]) * mix;
const bC = col.glow[2] + (col.lilac[2] - col.glow[2]) * mix;
const rad = 2.1 + 1.1 * (1 - mix);
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},0.55)`;
ctx.fillRect(fx2 - rad, fy2 - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.mist[0]},${col.mist[1]},${col.mist[2]},0.9)`;
ctx.beginPath();
ctx.arc(cx, cy, 2.4, 0, TAU);
ctx.fill();
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
if (!manualRef.current) {
const ph = (t * TAU) / 5.4;
pxRef.current = 0.5 + 0.36 * Math.sin(ph + 0.3);
pyRef.current = 0.5 + 0.3 * Math.sin(1.7 * ph + 1.1);
}
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
glow: tokRGB(root, "--flock-glow"),
lilac: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildBoids();
fit();
function onResize(): void {
fit();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, maxSpeed, attract, swirl, trail]);
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="14"><ellipse cx="128" cy="128" rx="96" ry="40" /><ellipse cx="128" cy="128" rx="40" ry="96" transform="rotate(45 128 128)" /></g><circle cx="128" cy="128" r="20" fill="currentColor" /></svg>
<span>중앙 인력점 하나가 {n}마리를 느린 소용돌이로 붙잡습니다 — 포인터 x 는 회전, y 는 인력 세기</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<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}>createRadialGradient · 이웃 3벡터 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · 餌を中心に — 引力の周りを回る軌道 — 計測 累積面積 31.175% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
引力は中心ほど強く渦は外まで届く
var orbR = 270 * p.orbBias; // 個体ごとの目標軌道半径
var decay = Math.exp(-(d * d) / (orbR * orbR)); // ガウス減衰 — 中心だけ強く引き外は自由軌道
var pull = -ATTRACT * pullMul * decay;
var tx = -ny, ty = nx; // 接線(進路と直交)
var swirlR = 300 * p.orbBias;
var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
var ff = flockForce(i2, cx, cy);
var ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];
var damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;
群れの力は隣人の統計だけ
function flockForce(idx, cx0, cy0) {
var p = boids[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (var j = 0; j < N; j++) {
if (j === idx) continue;
var q = boids[j];
var dx = q.x - p.x, dy = q.y - p.y;
if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10); fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40); fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
ATTRACT 26 ・ SWIRL 40 ・ MAX_SPEED 1.6(速さの単位 SPEED_UNIT 140px/s)で回り、ポインタの横軸が渦の強さ spinMul を、縦軸が引きの強さ pullMul を 0.5~1.6 倍へ動かします。計測は累積面積 31.175% ・強度の平均 84.0 ・動いたフレーム 23/24 です。
08発光プランクトン — 夜の海で光る群れ
180個の光が夜の海で漂い、近づくポインタから逃げて、発光は lighter 合成のスプライト1枚を drawImage で並べる盤です。

<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="currentColor"><circle cx="80" cy="96" r="10"/><circle cx="140" cy="70" r="7"/><circle cx="176" cy="130" r="12"/><circle cx="96" cy="160" r="8"/><circle cx="160" cy="190" r="6"/></g><path d="M32 200q32-24 64 0t64 0t64 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" opacity=".5"/></svg>
<span>180匹の発光プランクトンが夜の海を漂います — ポインターを近づけると光の群れが散って逃げます</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="cdN">180</b>匹・lighter発光合成</span>
<span class="cd__meta cd__meta--dim">drawImageスプライト・globalCompositeOperation・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 발광 플랑크톤(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(밤바다 그라디언트 + 발광 스프라이트 lighter 합성)가 만든다.
// 무대는 밤바다 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색(라일락 발광 개체)
$flock-mist: #d8dce6; // 먼 개체 흐릿
// ── 심해 전용 확장(08 만 — 심해 그라디언트·발광 톤. 전부 HLS 파스텔 안전값) ──
$flock-deep: #2c3750; // 심해 바닥 — 슬레이트보다 한 단 깊은 청
$flock-sea: #46608c; // 중간 수심
$flock-shallow: #5d7cae; // 얕은 수심 — 수면 쪽
$flock-plankton: #b4def0; // 발광 플랑크톤 기본 톤(파스텔 하늘)
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-deep: #{$flock-deep};
--flock-sea: #{$flock-sea};
--flock-shallow: #{$flock-shallow};
--flock-plankton: #{$flock-plankton};
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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-deep;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-plankton; 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($flock-mist, .72); }
.cd__val { color: $flock-plankton; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-deep: #2c3750;
--flock-sea: #46608c;
--flock-shallow: #5d7cae;
--flock-plankton: #b4def0;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #2c3750;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #b4def0;
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, 220, 230, 0.72);
}
.cd__val {
color: #b4def0;
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). 밤바다 배경도 캔버스가 매 프레임 그린다(깊이별 그라디언트가 느리게 흔들린다).
// 개체 180마리는 미리 만든 발광 스프라이트(오프스크린 캔버스 1장, createRadialGradient)를
// drawImage 로 찍고 globalCompositeOperation 'lighter' 로 겹치면 밝아지게 한다.
// 포인터(또는 시드 고정 가상 자극) 근처에서는 도망 벡터가 더해지고, 표준 군집 3벡터를
// 약하게 얹어 무리가 흩어졌다 다시 느슨히 모이는 결을 만든다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 180; // 개체 수
var GLOW_R = 7; // 발광 반경(px) — 변형 "발광 반경" 축(5 → 12)
var FLEE_R = 46; // 도망 반경(px)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x08fee0d;
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_DEEP = tokRGB('--flock-deep');
var C_MID = tokRGB('--flock-sea');
var C_SHALL = tokRGB('--flock-shallow');
var C_GLOWA = tokRGB('--flock-plankton');
var C_GLOWB = tokRGB('--flock-lilac');
var C_MIST = tokRGB('--flock-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
// ── 발광 스프라이트(오프스크린 1장) — drawImage 로 찍어 비용을 낮춘다 ──
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
var sctx = sprite.getContext('2d');
(function buildSprite() {
var r = spriteSize / 2;
var g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
})();
// ── 이웃 반경 안 3벡터 파라미터(군집 코어 공통 뼈대) — 느슨한 학교 짓기 ──
var NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
var W_SEP = 14, W_ALI = 5, W_COH = 2.4;
var plks = [];
function spawn(p) {
p.x = rand() * 1; p.y = rand() * 1; // 0..1 정규화 좌표(필드에 곱해 사용)
p.vx = (rand() - 0.5) * 10;
p.vy = (rand() - 0.5) * 10;
p.ph = rand() * TAU;
p.hue = rand() < 0.16 ? 1 : 0; // 16% 는 라일락 톤
p.sz = 0.75 + rand() * 0.7;
}
for (var i = 0; i < N; i++) { var p = {}; spawn(p); plks.push(p); }
function flockForce(idx) {
var p = plks[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
var px = p.x * W, py = p.y * H;
for (var j = 0; j < N; j++) {
if (j === idx) continue;
var q = plks[j];
var qx = q.x * W, qy = q.y * H;
var dx = qx - px, dy = qy - py;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.0;
function draw(tSec, dt) {
// ── 밤바다 배경 — 깊이별 그라디언트가 느리게 흔들린다(캔버스가 매 프레임 그린다) ──
ctx.globalCompositeOperation = 'source-over';
var shiftA = 0.5 + 0.5 * Math.sin(tSec * 0.35);
var seaGrad = ctx.createLinearGradient(0, 0, 0, H);
seaGrad.addColorStop(0, 'rgb(' + lerpC(C_SHALL, C_MID, shiftA * 0.3) + ')');
seaGrad.addColorStop(0.5, 'rgb(' + lerpC(C_MID, C_DEEP, 0.4 + shiftA * 0.2) + ')');
seaGrad.addColorStop(1, 'rgb(' + C_DEEP[0] + ',' + C_DEEP[1] + ',' + C_DEEP[2] + ')');
ctx.fillStyle = seaGrad;
ctx.fillRect(0, 0, W, H);
// 표류하는 해류 결 — 대각 띠가 위상만큼 흘러 화면 전체가 실제로 바뀐다
for (var band = 0; band < 3; band++) {
var by = H * (0.2 + band * 0.32) + Math.sin(tSec * 0.5 + band * 1.7) * H * 0.08;
var bandA = 0.05 + 0.03 * Math.sin(tSec * 0.8 + band);
ctx.fillStyle = 'rgba(' + C_MID[0] + ',' + C_MID[1] + ',' + C_MID[2] + ',' + bandA.toFixed(3) + ')';
ctx.fillRect(0, by - 14, W, 28);
}
// 가상 자극 — 포인터 없을 때 화면을 천천히 순회(시드 고정)
var stimX, stimY;
if (manual) { stimX = px01 * W; stimY = py01 * H; }
else {
stimX = W * (0.5 + 0.4 * Math.sin(tSec * 0.6));
stimY = H * (0.5 + 0.36 * Math.sin(tSec * 0.41 + 1.3));
}
ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
var p = plks[i2];
var fx3 = p.x * W, fy3 = p.y * H;
var dxs = fx3 - stimX, dys = fy3 - stimY;
var ds = Math.hypot(dxs, dys) || 0.0001;
var fleeX = 0, fleeY = 0;
if (ds < FLEE_R) {
var fleeMag = (1 - ds / FLEE_R) * 120;
fleeX = (dxs / ds) * fleeMag;
fleeY = (dys / ds) * fleeMag;
}
var ff = flockForce(i2);
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3; // 살짝 위로 뜨는 성향
var ax = fleeX + ff[0] + driftX;
var ay = fleeY + ff[1] + driftY;
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.88, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
var maxV = 70;
if (sp > maxV) { p.vx = p.vx / sp * maxV; p.vy = p.vy / sp * maxV; }
var nx2 = (fx3 + p.vx * dt + W) % W;
var ny2 = (fy3 + p.vy * dt + H) % H;
p.x = nx2 / W; p.y = ny2 / H;
var glow = p.hue ? C_GLOWB : C_GLOWA;
var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.filter = 'none';
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
}
}
// 색조 스프라이트 캐시 — 두 색(플랑크톤/라일락)만 쓰므로 두 장만 만든다
var tintCache = {};
function tintedSprite(rgb) {
var key = rgb.join(',');
if (tintCache[key]) return tintCache[key];
var c = document.createElement('canvas');
c.width = spriteSize; c.height = spriteSize;
var cc = c.getContext('2d');
cc.drawImage(sprite, 0, 0);
cc.globalCompositeOperation = 'source-in';
cc.fillStyle = 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
cc.fillRect(0, 0, spriteSize, spriteSize);
tintCache[key] = c;
return c;
}
function lerpC(a, b, m) {
return (a[0] + (b[0] - a[0]) * m | 0) + ',' + (a[1] + (b[1] - a[1]) * m | 0) + ',' + (a[2] + (b[2] - a[2]) * m | 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
nOut.textContent = String(N);
}
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;
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); px01 = 0.5; py01 = 0.5; }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GlowPlankton — vanilla 판(run/315/demo/08-flock-glow-plankton/index.html) 의 React 포트.
// 밤바다 배경(깊이별 그라디언트가 느리게 흔들린다)도 캔버스가 매 프레임 그린다. 개체는
// 미리 만든 발광 스프라이트(오프스크린 캔버스, createRadialGradient)를 drawImage 로 찍고
// globalCompositeOperation 'lighter' 로 겹쳐 밝아지게 한다. 포인터(또는 가상 자극) 근처에서는
// 도망 벡터가 더해지고, 표준 군집 3벡터를 약하게 얹어 무리가 흩어졌다 다시 느슨히 모인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlowPlankton.module.scss";
export type GlowPlanktonProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수
glowR?: number; // 발광 반경(px) — 변형 "발광 반경" 축(5 → 12)
fleeR?: number; // 도망 반경(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Plankton = { x: number; y: number; vx: number; vy: number; ph: number; hue: number; sz: number };
type Colors = { deep: RGB; sea: RGB; shallow: RGB; glowA: RGB; glowB: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "발광 플랑크톤 밤바다 실험판";
const STILL_T = 1.0;
const NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
const W_SEP = 14, W_ALI = 5, W_COH = 2.4;
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
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;
};
}
function lerpC(a: RGB, b: RGB, m: number): string {
return `${(a[0] + (b[0] - a[0]) * m) | 0},${(a[1] + (b[1] - a[1]) * m) | 0},${(a[2] + (b[2] - a[2]) * m) | 0}`;
}
export default function GlowPlankton({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b4def0",
count = 180,
glowR = 7,
fleeR = 46,
}: GlowPlanktonProps) {
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 spriteRef = useRef<HTMLCanvasElement | null>(null);
const spriteSizeRef = useRef(0) as Box<number>;
const tintCacheRef = useRef<Record<string, HTMLCanvasElement>>({}) as Box<Record<string, HTMLCanvasElement>>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const plksRef = useRef<Plankton[]>([]) as Box<Plankton[]>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
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 buildSprite(): void {
const spriteSize = Math.max(8, Math.round(glowR * 6));
spriteSizeRef.current = spriteSize;
const sprite = document.createElement("canvas");
sprite.width = spriteSize; sprite.height = spriteSize;
const sctx = sprite.getContext("2d");
if (sctx) {
const r = spriteSize / 2;
const g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(0.35, "rgba(255,255,255,0.55)");
g.addColorStop(1, "rgba(255,255,255,0)");
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
}
spriteRef.current = sprite;
tintCacheRef.current = {};
}
function tintedSprite(rgb: RGB): HTMLCanvasElement {
const key = rgb.join(",");
const cache = tintCacheRef.current;
if (cache[key]) return cache[key];
const spriteSize = spriteSizeRef.current;
const c = document.createElement("canvas");
c.width = spriteSize; c.height = spriteSize;
const cc = c.getContext("2d")!;
if (spriteRef.current) cc.drawImage(spriteRef.current, 0, 0);
cc.globalCompositeOperation = "source-in";
cc.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
cc.fillRect(0, 0, spriteSize, spriteSize);
cache[key] = c;
return c;
}
function spawn(rand: () => number): Plankton {
return {
x: rand(), y: rand(),
vx: (rand() - 0.5) * 10,
vy: (rand() - 0.5) * 10,
ph: rand() * TAU,
hue: rand() < 0.16 ? 1 : 0,
sz: 0.75 + rand() * 0.7,
};
}
function buildPlks(): void {
const rand = makePRNG(0x08fee0d);
const arr: Plankton[] = [];
for (let i = 0; i < count; i++) arr.push(spawn(rand));
plksRef.current = arr;
setN(String(count));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function flockForce(idx: number): [number, number] {
const plks = plksRef.current;
const { w: W, h: H } = sizeRef.current;
const p = plks[idx];
const px = p.x * W, py = p.y * H;
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < plks.length; j++) {
if (j === idx) continue;
const q = plks[j];
const qx = q.x * W, qy = q.y * H;
let dx = qx - px, dy = qy - py;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const plks = plksRef.current;
if (!ctx || !col || plks.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
const shiftA = 0.5 + 0.5 * Math.sin(tSec * 0.35);
const seaGrad = ctx.createLinearGradient(0, 0, 0, H);
seaGrad.addColorStop(0, `rgb(${lerpC(col.shallow, col.sea, shiftA * 0.3)})`);
seaGrad.addColorStop(0.5, `rgb(${lerpC(col.sea, col.deep, 0.4 + shiftA * 0.2)})`);
seaGrad.addColorStop(1, `rgb(${col.deep[0]},${col.deep[1]},${col.deep[2]})`);
ctx.fillStyle = seaGrad;
ctx.fillRect(0, 0, W, H);
for (let band = 0; band < 3; band++) {
const by = H * (0.2 + band * 0.32) + Math.sin(tSec * 0.5 + band * 1.7) * H * 0.08;
const bandA = 0.05 + 0.03 * Math.sin(tSec * 0.8 + band);
ctx.fillStyle = `rgba(${col.sea[0]},${col.sea[1]},${col.sea[2]},${bandA.toFixed(3)})`;
ctx.fillRect(0, by - 14, W, 28);
}
let stimX: number, stimY: number;
if (manualRef.current) { stimX = pxRef.current * W; stimY = pyRef.current * H; }
else {
stimX = W * (0.5 + 0.4 * Math.sin(tSec * 0.6));
stimY = H * (0.5 + 0.36 * Math.sin(tSec * 0.41 + 1.3));
}
ctx.globalCompositeOperation = "lighter";
const spriteSize = spriteSizeRef.current;
for (let i2 = 0; i2 < plks.length; i2++) {
const p = plks[i2];
const fx3 = p.x * W, fy3 = p.y * H;
const dxs = fx3 - stimX, dys = fy3 - stimY;
const ds = Math.hypot(dxs, dys) || 0.0001;
let fleeX = 0, fleeY = 0;
if (ds < fleeR) {
const fleeMag = (1 - ds / fleeR) * 120;
fleeX = (dxs / ds) * fleeMag;
fleeY = (dys / ds) * fleeMag;
}
const ff = flockForce(i2);
const driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
const driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3;
const ax = fleeX + ff[0] + driftX;
const ay = fleeY + ff[1] + driftY;
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.88, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
const maxV = 70;
if (sp > maxV) { p.vx = (p.vx / sp) * maxV; p.vy = (p.vy / sp) * maxV; }
const nx2 = (fx3 + p.vx * dt + W) % W;
const ny2 = (fy3 + p.vy * dt + H) % H;
p.x = nx2 / W; p.y = ny2 / H;
const glow = p.hue ? col.glowB : col.glowA;
const pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
const dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
}
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void { manualRef.current = false; }
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; pxRef.current = 0.5; pyRef.current = 0.5; }
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
deep: tokRGB(root, "--flock-deep"),
sea: tokRGB(root, "--flock-sea"),
shallow: tokRGB(root, "--flock-shallow"),
glowA: tokRGB(root, "--flock-plankton"),
glowB: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildSprite();
buildPlks();
fit();
function onResize(): void {
fit();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, glowR, fleeR]);
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="currentColor"><circle cx="80" cy="96" r="10" /><circle cx="140" cy="70" r="7" /><circle cx="176" cy="130" r="12" /><circle cx="96" cy="160" r="8" /><circle cx="160" cy="190" r="6" /></g><path d="M32 200q32-24 64 0t64 0t64 0" fill="none" stroke="currentColor" strokeWidth="10" strokeLinecap="round" opacity=".5" /></svg>
<span>{n}마리 발광 플랑크톤이 밤바다를 떠돕니다 — 포인터를 가까이 대면 빛무리가 흩어지며 도망칩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 · lighter 발광 합성</span>
<span className={styles.metaDim}>drawImage 스프라이트 · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · 発光プランクトン — 夜の海で光る群れ — 計測 累積面積 32.674% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
グローは白い丸1枚に焼いてある
// 発光スプライト(オフスクリーン1枚) — drawImage で並べて費用を下げる
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
var sctx = sprite.getContext('2d');
var r = spriteSize / 2;
var g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
光は脈打ち体は静かに浮かぶ
// ゆらぎ — 位相ごとの sin で横に揺れ、少し上へ浮く癖
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3;
var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.globalAlpha = 0.55 + 0.35 * pulse;
逃げは FLEE_R 46px で (1 - d/46) × 120 の力、16% の個体は hue フラグでライラックに染まり GLOW_R 7px の球が重なって光ります。計測は累積面積 32.674% ・強度の平均 17.5(9枚で最も静か)・動いたフレーム 23/24 です。
09文字を成す群れ — ポインタを避けて集まる文章
太字の BOIDS の輪郭に個体が目標点を持って集まり、ポインタが近づくと一度乱れてまた文字を組み直す盤です。

<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="currentColor" opacity=".9"><rect x="36" y="52" width="26" height="26" rx="4"/><rect x="88" y="40" width="18" height="18" rx="3"/><rect x="150" y="58" width="22" height="22" rx="4"/><rect x="60" y="112" width="20" height="20" rx="4"/><rect x="124" y="126" width="26" height="26" rx="5"/><rect x="182" y="104" width="16" height="16" rx="3"/><rect x="46" y="176" width="22" height="22" rx="4"/><rect x="110" y="188" width="18" height="18" rx="3"/><rect x="168" y="172" width="24" height="24" rx="4"/></g></svg>
<span>文章そのものが目標点です — ポインター(または矢印キー)で文字をかき分けると群れが散らばり、離れるとBOIDSに再び組み上がります</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="cdN">320</b>個の目標点・スプリング再構成</span>
<span class="cd__meta cd__meta--dim">getImageDataサンプリング・fillTextオフスクリーン・requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 글자를 이루는 떼(09). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(목표점 스프링 + 군집 3벡터 + 도망)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색(흩어진 개체가 물드는 색)
$flock-mist: #d8dce6; // 글자 색 — 먼 개체 흐릿
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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($flock-mist, .72); }
.cd__val { color: $flock-glow; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
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). 오프스크린 캔버스에 굵은 산세리프로 "BOIDS" 를 fillText 로 칠고
// getImageData 로 밝은 픽셀 좌표를 샘플(약 300~400점)해 개체별 목표점으로 배정한다.
// 스티어링 = 목표점 스프링(강) + 군집 3규칙(약) + 포인터 도망 벡터.
// 포인터가 없는 렌더 환경에서는 시드 고정 가상 포인터가 주기적으로 문장을 가로질러
// 흩었다 모으게 한다(23/23 프레임 보장). 오프스크린은 화면에 안 보인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 320; // 개체 수(샘플 점 수에 맞춰 상한에서 잘린다)
var FLEE_R = 120; // 도망 반경(px) — 변형 "도망 반경" 축(28 → 120), 화면 넓은 영역까지 흩어진다
var SPRING = 6.0; // 목표점 스프링 세기 — 낮춰 흩어진 뒤 되돌아오는 데 시간이 걸리게 한다
var TRAIL = 0.07; // 잔상 덮개 알파 — 흩어지는 궤적을 남겨 누적면적을 넓힌다
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x09b0d5;
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('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_LILAC = tokRGB('--flock-lilac');
var C_GLOW = tokRGB('--flock-glow');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5; // 포인터(0..1) — 없으면 가상
var manual = false;
var firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
// ── 이웃 반경 안 3벡터(약하게) ──
var NEI_R = 22, NEI_R2 = NEI_R * NEI_R, SEP_R = 8;
var W_SEP = 9, W_ALI = 2.4, W_COH = 1.2;
// ── 목표점 샘플 — 오프스크린 fillText → getImageData ──
var targets = []; // {x,y} 정규화(0..1) 좌표
var boids = [];
var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4; // 픽셀 샘플 간격
function buildTargets() {
targets = [];
var off = document.createElement('canvas');
off.width = Math.max(64, Math.round(W));
off.height = Math.max(32, Math.round(H));
var octx = off.getContext('2d');
octx.fillStyle = '#000';
octx.fillRect(0, 0, off.width, off.height);
octx.fillStyle = '#fff';
octx.font = FONT_STACK;
octx.textAlign = 'center';
octx.textBaseline = 'middle';
var word = 'BOIDS';
octx.fillText(word, off.width / 2, off.height / 2);
var availW = off.width * 0.92;
var m = octx.measureText(word);
if (m.width > availW) {
// 폭이 넘치면 글꼴 크기를 비례 축소해 다시 칠한다
var scale = availW / m.width;
var px2 = Math.max(28, Math.floor(100 * scale));
octx.font = '900 ' + px2 + 'px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
octx.fillText(word, off.width / 2, off.height / 2);
}
var img = octx.getImageData(0, 0, off.width, off.height);
var d = img.data, ow = off.width, oh = off.height;
for (var yy = 0; yy < oh; yy += sampleGap) {
for (var xx = 0; xx < ow; xx += sampleGap) {
if (d[(yy * ow + xx) * 4] > 128) {
targets.push({ x: xx / ow, y: yy / oh });
}
}
}
// 개체 수를 목표점 수에 맞춰 재배치(목표 인덱스는 그대로 유지)
var count = Math.min(N, targets.length);
boids = [];
for (var i = 0; i < count; i++) {
var tg = targets[i % targets.length];
boids.push({
x: tg.x * W + (rand() - 0.5) * 40,
y: tg.y * H + (rand() - 0.5) * 40,
vx: (rand() - 0.5) * 20,
vy: (rand() - 0.5) * 20,
tx: tg.x, ty: tg.y,
ph: rand() * TAU
});
}
nOut.textContent = String(count);
}
function flockForce(idx) {
var p = boids[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (var j = 0; j < boids.length; j++) {
if (j === idx) continue;
var q = boids[j];
var dx = q.x - p.x, dy = q.y - p.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.5;
function draw(tSec, dt) {
// 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 TRAIL 로 흩어지는 궤적을 남긴다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
firstFrame = false;
// 가상 포인터 — 주기적으로 문장을 가로질러 흩었다 모으게(23/23 보장)
var sX, sY;
if (manual) { sX = px01 * W; sY = py01 * H; }
else {
var cyc = (tSec % 6.2) / 6.2; // 6.2초 주기 — 더 자주 문장을 가로지른다
var sweep = Math.sin(cyc * TAU);
sX = W * (0.5 + sweep * 0.5);
sY = H * (0.5 + 0.4 * Math.cos(cyc * TAU * 2));
}
// ── 목표점 미광 — 문장 자리를 은은히 비춘다(재조립 방향 감각) ──
var breathe = 0.85 + 0.15 * Math.sin(tSec * 1.8);
var gA = 0.05 * breathe;
var rg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.05, W / 2, H / 2, Math.max(W, H) * 0.52);
rg.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',' + gA.toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
// ── 개체 — 목표 스프링 + 군집(약) + 도망 ──
for (var i = 0; i < boids.length; i++) {
var p = boids[i];
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING;
var ff = flockForce(i);
ax += ff[0]; ay += ff[1];
var dxs = p.x - sX, dys = p.y - sY;
var ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < FLEE_R) {
var fm = (1 - ds / FLEE_R) * 460;
ax += (dxs / ds) * fm;
ay += (dys / ds) * fm;
}
var jitX = Math.sin(tSec * 6.3 + p.ph) * 34;
var jitY = Math.cos(tSec * 5.1 + p.ph * 1.3) * 30;
ax += jitX; ay += jitY;
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.86, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
var maxV = 260;
if (sp > maxV) { p.vx = p.vx / sp * maxV; p.vy = p.vy / sp * maxV; }
p.x += p.vx * dt; p.y += p.vy * dt;
// 글자 색 $flock-mist — 도망 중(목표에서 멀수록) 라일락으로 물든다
var far = Math.min(1, Math.hypot(dx, dy) / 60);
var rC = C_MIST[0] + (C_LILAC[0] - C_MIST[0]) * far;
var gC = C_MIST[1] + (C_LILAC[1] - C_MIST[1]) * far;
var bC = C_MIST[2] + (C_LILAC[2] - C_MIST[2]) * far;
var rad = 2.6;
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',0.85)';
ctx.fillRect(p.x - rad, p.y - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = 'source-over';
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
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;
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); px01 = 0.5; py01 = 0.5; }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); buildTargets(); if (reduce) draw(STILL_T, 0.016); });
fit();
buildTargets();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// TextGlyphSwarm — vanilla 판(run/315/demo/09-flock-text-glyph-swarm/index.html) 의 React 포트.
// 오프스크린 캔버스에 굵은 산세리프로 "BOIDS" 를 fillText 로 칠고 getImageData 로 밝은 픽셀
// 좌표를 샘플해 개체별 목표점으로 배정한다. 스티어링 = 목표점 스프링(강) + 군집 3규칙(약) +
// 포인터 도망 벡터. 포인터가 없으면 시드 고정 가상 포인터가 주기적으로 문장을 가로지른다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TextGlyphSwarm.module.scss";
export type TextGlyphSwarmProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수(샘플 점 수에 맞춰 상한에서 잘린다)
fleeR?: number; // 도망 반경(px) — 변형 "도망 반경" 축(28 → 120)
spring?: number; // 목표점 스프링 세기
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Target = { x: number; y: number };
type Boid = { x: number; y: number; vx: number; vy: number; tx: number; ty: number; ph: number };
type Colors = { ink: RGB; mist: RGB; lilac: RGB; glow: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "글자를 이루는 군집 실험판 — 화살표키로 글자를 헤칠 수 있습니다";
const STILL_T = 1.5;
const TRAIL = 0.07;
const NEI_R = 22, NEI_R2 = NEI_R * NEI_R, SEP_R = 8;
const W_SEP = 9, W_ALI = 2.4, W_COH = 1.2;
const FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
const SAMPLE_GAP = 4;
const WORD = "BOIDS";
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
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 TextGlyphSwarm({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
count = 320,
fleeR = 120,
spring = 6.0,
}: TextGlyphSwarmProps) {
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 boidsRef = useRef<Boid[]>([]) as Box<Boid[]>;
const randRef = useRef<() => number>(makePRNG(0x09b0d5)) as Box<() => number>;
const firstRef = useRef(true) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
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)];
}
// 오프스크린 fillText → getImageData 로 목표점 샘플, 개체 재배치
function buildTargets(): void {
const rand = randRef.current;
const { w: W, h: H } = sizeRef.current;
const targets: Target[] = [];
const off = document.createElement("canvas");
off.width = Math.max(64, Math.round(W));
off.height = Math.max(32, Math.round(H));
const octx = off.getContext("2d");
if (!octx) return;
octx.fillStyle = "#000";
octx.fillRect(0, 0, off.width, off.height);
octx.fillStyle = "#fff";
octx.font = FONT_STACK;
octx.textAlign = "center";
octx.textBaseline = "middle";
octx.fillText(WORD, off.width / 2, off.height / 2);
const availW = off.width * 0.92;
const m = octx.measureText(WORD);
if (m.width > availW) {
const scale = availW / m.width;
const px2 = Math.max(28, Math.floor(100 * scale));
octx.font = `900 ${px2}px "Segoe UI", "Helvetica Neue", Arial, sans-serif`;
octx.fillText(WORD, off.width / 2, off.height / 2);
}
const img = octx.getImageData(0, 0, off.width, off.height);
const d = img.data, ow = off.width, oh = off.height;
for (let yy = 0; yy < oh; yy += SAMPLE_GAP) {
for (let xx = 0; xx < ow; xx += SAMPLE_GAP) {
if (d[(yy * ow + xx) * 4] > 128) targets.push({ x: xx / ow, y: yy / oh });
}
}
const cnt = Math.min(count, targets.length);
const arr: Boid[] = [];
for (let i = 0; i < cnt; i++) {
const tg = targets[i % targets.length];
arr.push({
x: tg.x * W + (rand() - 0.5) * 40,
y: tg.y * H + (rand() - 0.5) * 40,
vx: (rand() - 0.5) * 20,
vy: (rand() - 0.5) * 20,
tx: tg.x, ty: tg.y,
ph: rand() * TAU,
});
}
boidsRef.current = arr;
setN(String(cnt));
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
firstRef.current = true;
}
function flockForce(idx: number): [number, number] {
const boids = boidsRef.current;
const { w: W, h: H } = sizeRef.current;
const p = boids[idx];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < boids.length; j++) {
if (j === idx) continue;
const q = boids[j];
let dx = q.x - p.x, dy = q.y - p.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const boids = boidsRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},${firstRef.current ? 1 : TRAIL})`;
ctx.fillRect(0, 0, W, H);
firstRef.current = false;
let sX: number, sY: number;
if (manualRef.current) { sX = pxRef.current * W; sY = pyRef.current * H; }
else {
const cyc = (tSec % 6.2) / 6.2;
const sweep = Math.sin(cyc * TAU);
sX = W * (0.5 + sweep * 0.5);
sY = H * (0.5 + 0.4 * Math.cos(cyc * TAU * 2));
}
const breathe = 0.85 + 0.15 * Math.sin(tSec * 1.8);
const gA = 0.05 * breathe;
const rg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.05, W / 2, H / 2, Math.max(W, H) * 0.52);
rg.addColorStop(0, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},${gA.toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < boids.length; i++) {
const p = boids[i];
const dx = p.tx * W - p.x, dy = p.ty * H - p.y;
let ax = dx * spring, ay = dy * spring;
const ff = flockForce(i);
ax += ff[0]; ay += ff[1];
const dxs = p.x - sX, dys = p.y - sY;
const ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < fleeR) {
const fm = (1 - ds / fleeR) * 460;
ax += (dxs / ds) * fm;
ay += (dys / ds) * fm;
}
const jitX = Math.sin(tSec * 6.3 + p.ph) * 34;
const jitY = Math.cos(tSec * 5.1 + p.ph * 1.3) * 30;
ax += jitX; ay += jitY;
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.86, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
const maxV = 260;
if (sp > maxV) { p.vx = (p.vx / sp) * maxV; p.vy = (p.vy / sp) * maxV; }
p.x += p.vx * dt; p.y += p.vy * dt;
const far = Math.min(1, Math.hypot(dx, dy) / 60);
const rC = col.mist[0] + (col.lilac[0] - col.mist[0]) * far;
const gC = col.mist[1] + (col.lilac[1] - col.mist[1]) * far;
const bC = col.mist[2] + (col.lilac[2] - col.mist[2]) * far;
const rad = 2.6;
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},0.85)`;
ctx.fillRect(p.x - rad, p.y - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void { manualRef.current = false; }
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; pxRef.current = 0.5; pyRef.current = 0.5; }
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
lilac: tokRGB(root, "--flock-lilac"),
glow: tokRGB(root, "--flock-glow"),
};
randRef.current = makePRNG(0x09b0d5);
fit();
buildTargets();
function onResize(): void {
fit();
buildTargets();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, fleeR, spring]);
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="currentColor" opacity=".9"><rect x="36" y="52" width="26" height="26" rx="4" /><rect x="88" y="40" width="18" height="18" rx="3" /><rect x="150" y="58" width="22" height="22" rx="4" /><rect x="60" y="112" width="20" height="20" rx="4" /><rect x="124" y="126" width="26" height="26" rx="5" /><rect x="182" y="104" width="16" height="16" rx="3" /><rect x="46" y="176" width="22" height="22" rx="4" /><rect x="110" y="188" width="18" height="18" rx="3" /><rect x="168" y="172" width="24" height="24" rx="4" /></g></svg>
<span>문장이 곧 목표점입니다 — 포인터(또는 화살표키)로 글자를 헤치면 떼가 흩어지고, 비키면 BOIDS 로 다시 조립됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<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}>getImageData 샘플링 · fillText 오프스크린 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · 文字を成す群れ — ポインタを避けて集まる文章 — 計測 累積面積 31.423% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
輪郭は fillText の明るい画素から採る
// 目標点の抽出 — オフスクリーン fillText → getImageData
var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4; // 画素のサンプリング間隔
octx.font = FONT_STACK;
octx.textAlign = 'center';
octx.textBaseline = 'middle';
var word = 'BOIDS';
octx.fillText(word, off.width / 2, off.height / 2);
var img = octx.getImageData(0, 0, off.width, off.height);
for (var yy = 0; yy < oh; yy += sampleGap) {
for (var xx = 0; xx < ow; xx += sampleGap) {
if (d[(yy * ow + xx) * 4] > 128) {
targets.push({ x: xx / ow, y: yy / oh }); // 正規化座標で貯める
}
}
}
ばねで戻り斥力で散る
// 目標へのばね + 群れ(弱) + 逃走
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING;
var ff = flockForce(i);
ax += ff[0]; ay += ff[1];
var ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < FLEE_R) {
var fm = (1 - ds / FLEE_R) * 460;
ax += (dxs / ds) * fm; ay += (dys / ds) * fm;
}
個体は N=320 から目標点の数へ刈り込まれ、SPRING 6.0 のばねで輪郭に戻り FLEE_R 120px では (1 - d/120) × 460 の斥力を受けます。ポインタがなければ 6.2秒周期の仮想ポインタが文を横切り、計測は累積面積 31.423% ・強度の平均 50.2 ・動いたフレーム 23/24 です。
群れシミュレーションを実際に使う画面
ロゴやブランドの一文が散らばってからまた集まるイントロには、09の文字を成す群れがそのまま合います。ポインターを避けてから文章へ戻る動きが、ヘッダー領域の視線をつかみます。カードのホバーやローディングのオーバーレイのような暗い背景には、光だけを残す08の発光プランクトンが重くならずに馴染みます。
背景のスクロールセクションには、流線を残して大きく回る06のマミュレーションや、位相がずれて静かに繰り返す02の鳥の群れシルエットが合い、どちらもテキストに重ならない余白だけに置きます。
近傍半径を広げすぎると群れがここで壊れる落とし穴
結合を強くし過ぎると群れが震える
01の結合は隣人の重心へ 2.2 倍の目標速度で寄せます。この係数と W_COH を同時に強めると重心へ向かう加速が分離を上回り、個体は重心を行き過ぎて引き返す振動に落ち、塊が細かく震えます。結合を上げるときは分離も一緒に上げるか、目標速度の係数を落とすのが定石です。
有限差分の間隔で風が壊れる
06の windAt は間隔 e=1.5px の差分で回転を計ります。e を 0.5px などへ詰めると差分が画素の粒より細かくなって速度が跳ね、風がガタつきます。逆に 10px 以上へ広げると3つの正弦の山が平均されて渣が消え、平板な流れに変わります。
升が知覚半径より小さいと隣人が漏れる
05の3×3探索は CELL 48px ≥ PERCEP 30px という条件の上で正しいです。CELL を知覚半径より小さくすると枠の外に知覚圏がはみ出し、隣の升の仲間との分離だけが起きなくなって群れが縦に裂けます。直すには探索を5×5へ広げるか、CELL ≥ PERCEP を守ります。
目標点より個体が多いときの余り
09では目標点が N=320 より少なくなる幅があるため、count = Math.min(N, targets.length) で個体数を刈り込み、targets[i % targets.length] の循環で余りを処理します。このどちらかを外すと未定義の目標を引いた個体が NaN に沈み、文字が二度と組み上がりません。9つの群れのバニラ版とReact版は記事の下のパスワード付きzipに全部入っていて、解錠のパスワードはnhgr9myqです。
近傍数ひとつで変わる3つのバリエーションとアクセシビリティ対応
値1つを変えた3つの変種
| 名前 | 変える値 | 一言の感想 |
|---|---|---|
| 個体数 | 01 N_B 260 → 640 | 雲が濃くなる代償にフレームが重くなる |
| 結合の強さ | 03 W_COH 0.6 → 1.2 | 塊は締まるが泳ぎが鈍る |
| 色 | $flock-ink → $flock-lilac | 墨色の羽がライラックの波に変わる |
動きを減らした設定では1枚を返す
9枚すべての盤は prefers-reduced-motion: reduce の検出でアニメーションのループに入らず、時刻を固定した1枚だけを描きます。01・02・03は 1.6秒、07は 1.2秒、08は 1.0秒、09は 1.5秒の時刻を採り、04は逃走が始まった直後、05は千羽が整った瞬間、06は風が軌跡を描いた後の静止画です。描くコードは動いているときと同じなので品質は変わりません。
矢印キーで動かせる5枚
9枚すべての盤に role="application" と aria-label と tabindex="0" が付いており、Tab で盤に入れます。矢印キーで動くのは04(捕食者)・05(風の信号)・07(横軸で渦・縦軸で引き)・08(逃げの中心)・09(文字を掻き乱す)の5枚です。
群れシミュレーションを組み込む前によくある質問
鳥 の 群れ アルゴリズムを書くのに必要な数学はどの程度ですか?
必要なのは sin と atan2 と hypot だけです。向きは atan2、羽や尾の位相は sin、距離は hypot で間に合い、微分も行列も登場しません。唯一ひねりの利いた式は01の分離の重み w の分数で、近いほど効く1/d に似た形を四則で書いているだけです。
個体数はどこまで増やせますか?
全数比較では n(n-1) 回の判定が毎フレーム必要で、1000羽でおよそ100万回(組にして約50万)になります。05の空間ハッシュに替えれば隣接9升だけを見るので増え方が線形にとどまり、変種の 2000羽 までそのまま回せます。先に当たる壁は計算より canvas への描画の費用です。
9つの盤を自分のページに貼っても良いですか?
良いです。記事の下のzipには9つの群れのバニラ版とReact版が入っていて、貼ればそのまま動きます。色は各盤のscss冒頭のトークン変数で入れ替えられ、N_B や W_COH といった定数を1つ書き換えるだけで個体数・塊の固さ・風の強さも変わります。









