9 Canvas Boids Algorithm Flocking Birds Simulation
A boids algorithm demo steers every agent with three local vectors, separation, alignment, cohesion, and that is enough to raise hundreds of birds from three
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 The three-rule flock — a minimal swarm on separation, alignment, cohesion
- 02 Bird silhouettes — feathers whose wingbeat phases drift apart
- 03 A school of fish — schooling with a sense of depth
- 04 A predator arrives — a flee vector splitting the flock
- 05 The arithmetic of a thousand — finding neighbors with a spatial hash
- 06 Murmuration — a black cloud that leaves flow lines
- 07 Around the food — orbits near an attractor
- 08 Glowing plankton — a swarm shining in the night sea
- 09 A flock that spells — a sentence assembling while dodging the pointer
Why three simple rules branch into nine flocks
Each step adds one more force on top of the same three-vector core, and reading them in order is the fastest way to see how little a flock actually needs. 01 is the bare minimum — separation, alignment, cohesion and nothing else — while 02 and 03 dress that same core in drawing (wingbeat phase, virtual depth) without touching the physics. 04 is the first scene to add a fourth force, flee, and 05 keeps every force but rebuilds how neighbors are found. 06 blows a wind field through the flock, 07 adds a weak attractor, 08 switches the paint step to glow compositing, and 09 spends its budget on target points sampled from text instead of free roaming. Trails accumulate starting at 06, so the last three scenes are also the first to leave a visible wake behind every agent.
Why build flocks from three rules? — separation, alignment, cohesion and the neighbor radius
The simplest way to make a crowd move together isn't to put one agent in charge — it's to give none of them any authority at all. Each bird only looks inside its own neighbor radius and folds that view into three numbers: push away if something is close (separation), match the surrounding heading (alignment), and drift toward the local center of mass (cohesion). Craig Reynolds proposed exactly this combination in 1987, and it's still the reference shape people mean when they say separation alignment cohesion or search for a flocking algorithm. Weight the three vectors, clamp the sum to a maximum force, and no single agent ever sees the whole flock — yet the flock reads as one coherent shape. What changes between these nine scenes is almost never the three rules themselves; it's the radius, the weights, and whatever fourth force gets bolted on (flee in 04 and 09, wind in 06, an attractor in 07). That's also what separates this boids algorithm simulation from 314's fire particles, where each particle burns down on its own with no notion of a neighbor, and from 306's spring-constrained physics mesh, where explicit joints — not a perception radius — hold points together.
Item code: nine boids algorithm simulation builds
01The three-rule flock — a minimal swarm on separation, alignment, cohesion
Adding three vectors — steer away, match the neighbors' heading, drift toward their center — makes a flock of 260 agents form itself with no leader at all, measured at 41.242% cumulative area across 23 of 24 moving frames.

<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>A single agent moves as the sum of three vectors — separation (pushed apart when close), alignment (matches heading), and cohesion (drawn to the center). Arrows light up on the representative agent</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Three-rule flocking test panel" 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>agents · separation · alignment · cohesion</span>
<span class="cd__meta cd__meta--dim">canvas · steering · 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 · The three-rule flock — a minimal swarm on separation, alignment, cohesion — Measured area 41.242% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Three vectors gathered inside the neighbor radius
var R_NEIGH = 42; // neighbor radius (px)
var MAX_SPD = 128; // max speed (px/s)
var MAX_FOR = 340; // max steering force (px/s^2)
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) { // separation — only inside half the radius, weighted stronger the closer it gets
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;
}
}
Every boid scans its neighbor radius once per frame, summing neighbor velocity and position while weighting separation more heavily the closer another boid sits.
Steering blends the three weights before clamping
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;
}
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA); // alignment
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB); // cohesion
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];
}
Screen edges stitched into a torus
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;
// torus wrap — exit one edge, re-enter from the opposite edge
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;
The combined force is clamped to a maximum speed before the position updates, and any boid drifting past one edge simply re-enters from the opposite side.
02Bird silhouettes — feathers whose wingbeat phases drift apart
Offsetting each bird's wingbeat phase a little and rotating the body to its heading composes a black flock crossing the sky as a single silhouette scene, measured at 39.212% cumulative area over 23 of 24 moving frames.

<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>A flock of birds cutting across the evening sky — two triangular wings flap on a phase offset per bird, and each body tilts toward its heading</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Bird-flock silhouette test panel" 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>birds · wingbeat phase</span>
<span class="cd__meta cd__meta--dim">ctx.rotate · sin phase · 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 · Bird silhouettes — feathers whose wingbeat phases drift apart — Measured area 39.212% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Wingbeat phase assigned at spawn, per bird
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; // wingbeat phase — offset per bird
b.flap = 5.2 + rand() * 3.4; // wingbeat speed (rad/s)
b.sz = 0.8 + rand() * 0.55; // wing scale
b.grp = (i || 0) % 4; // sub-flock — spreads screen coverage
}
Each bird draws its own random phase and flap speed at spawn, so the whole flock never beats its wings in lockstep.
Two wings fold on opposite phase
var flap = Math.sin(tSec * b.flap + b.ph); // -1..1 wing flap
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
var s = b.sz;
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; // front wing folds up when the phase is positive
var wy2 = -flap * 4.2 * s; // back wing folds on the opposite phase
A dusk sky that breathes without leaving a trail
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);
The sky is repainted as a full-opacity gradient every frame instead of a translucent overlay, so its stop position can drift with tSec and the whole backdrop stays alive behind the silhouettes.
03A school of fish — schooling with a sense of depth
Giving each fish a virtual depth and drawing far ones smaller and fainter layers a swimming school without any perspective projection, measured at 44.171% cumulative area over 23 of 24 moving frames.

<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>A fish school on a deep-sea stage — each fish carries a virtual depth z (0 to 1), so farther fish are drawn smaller and fainter to form layers, and streamlined bodies and tails lean along their heading</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Fish-school depth test panel" 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>fish · depth-z layers</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 · A school of fish — schooling with a sense of depth — Measured area 44.171% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Virtual depth sets both size and alpha
b.z = rand(); // virtual depth, 0 (near) .. 1 (far)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; }); // far-to-near so near fish overlap far ones
for (var k = 0; k < order.length; k++) {
var b = order[k];
var depth = 1 - b.z; // 1 = near, 0 = far
var sc = (0.45 + 0.75 * depth) * b.sz; // farther fish draw smaller
var alpha = 0.35 + 0.6 * depth; // farther fish draw fainter
var ang = Math.atan2(b.vy, b.vx);
}
Every fish carries a random depth value that scales both its size and opacity, so distant fish read as smaller and fainter without a camera or real projection matrix.
Streamlined body and tail lean on the heading axis
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.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();
A slow-breathing light stays above the school
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)');
The overhead light's radius and alpha both ride the same slow sine, so the whole deep-sea stage brightens and dims without a single fish changing course.
04A predator arrives — a flee vector splitting the flock
Treating the pointer as a predator and adding a strong flee vector inside a radius makes the flock split open and re-seal in a living ripple, measured at 40.232% cumulative area over 23 of 24 moving frames.

<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>The pointer becomes a predator — agents within its radius flee with a force inversely proportional to distance, splitting the flock apart before it re-forms. Without a pointer, a virtual predator patrols on its own</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Bird-flock predator-avoidance test panel. Use the arrow keys to move the predator" 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>agents · flee radius <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 · A predator arrives — a flee vector splitting the flock — Measured area 40.232% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A fourth force that scales with distance squared
var FLEE_R = 90; // predator flee radius (px)
var FLEE_GAIN = 1.0; // flee force multiplier
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, closer to 1 the nearer it is
var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN;
fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}
Squaring t before scaling the force means a bird an inch from the predator flees far harder than one at the radius edge, which is what tears the flock open instead of just nudging it.
Cohesion re-seals the gap the flee force tears open
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; }
Torus shortest distance still finds neighbors across the seam
var dx = o.x - b.x, dy = o.y - b.y;
// torus shortest distance — wrap to whichever side is closer
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++; }
}
05The arithmetic of a thousand — finding neighbors with a spatial hash
Dividing the screen into cells, registering each bird in one, and checking only the neighboring cells drops neighbor search from all-pairs to a grid lookup as the count grows, measured at 48.21% cumulative area over 23 of 24 moving frames with 1000 agents on screen.

<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>The screen is divided into 48px cells, and each agent checks only its own cell and the 8 adjacent ones — even with 1000 agents, the flock forms without an exhaustive 1,000×1,000 comparison</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Spatial-hash-grid boids test panel" 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>agents · neighbor checks per frame <b class="cd__val" id="cdChecks">0</b>checks</span>
<span class="cd__meta cd__meta--dim">Map hash · 3x3 cell scan · 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 · The arithmetic of a thousand — finding neighbors with a spatial hash — Measured area 48.21% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A Map hash is rebuilt from scratch every frame
var CELL = 48; // hash cell side (px)
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);
}
}
Because every bird moves each frame, the hash is thrown away and rebuilt each frame too, and a single "cx,cy" string key per cell keeps the data structure trivial.
Only the home cell and its 8 neighbors get checked
var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// home cell + 8 neighboring cells (3x3) only — this is the whole optimization
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++;
// distance test happens here — inside PERCEP (30) counts as a neighbor
}
}
}
Why a thousand birds stay smooth
An all-pairs comparison runs a million distance checks per frame at 1000 birds, but swapping in the 3x3 grid lookup drops that to a few tens of checks per bird, and the on-screen checksThisFrame counter turns that saving into a number you can watch move.
06Murmuration — a black cloud that leaves flow lines
Covering each frame translucently to leave trails and blowing curl-noise wind through the flock turns it into the black tide of starlings rising over a reed bed, measured at 51.635% cumulative area — the highest of all nine scenes — over 23 of 24 moving frames.

<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>Every frame lightly overlays the background so agent trails linger as flow lines — a pure sine-summed curl-noise wind drives a black wave of 400 agents</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Murmuration trail test panel" 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>agents · 0.14 trail overlay</span>
<span class="cd__meta cd__meta--dim">curl noise · trail accumulation · 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 · Murmuration — a black cloud that leaves flow lines — Measured area 51.635% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One translucent overlay alpha leaves the trail
var TRAIL = 0.14; // trail overlay alpha — lower leaves a longer trail
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' +
(firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
Curl noise is a rotational field built from stacked sines
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 windAt(x, y, tSec) {
var e = 1.5; // finite-difference step (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);
// rotational field — zero divergence, air never piles up or drains
var mag = 46 * WIND;
return [dpsiDy * mag, -dpsiDx * mag];
}
Wind is the perpendicular gradient of a scalar potential rather than a hand-tuned direction, which is exactly what keeps the flow divergence-free and never bunching birds into a single point.
Wind joins the boid force before the speed clamp
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; }
07Around the food — orbits near an attractor
Giving the flock one weak attractor and lowering each bird's speed cap turns the scene into a slow whirl of birds circling a feeding spot, measured at 31.175% cumulative area over 23 of 24 moving frames.

<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>A single central attractor holds 110 agents in a slow vortex — pointer x controls rotation, y controls pull strength</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Orbit vortex test panel" 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>agents · attractor vortex orbit</span>
<span class="cd__meta cd__meta--dim">createRadialGradient · 3 neighbor vectors · 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 · Around the food — orbits near an attractor — Measured area 31.175% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Gaussian-decayed pull plus a tangential swirl
var ATTRACT = 26; // pull strength — kept low so orbits spread wide
var orbR = 270 * p.orbBias; // per-bird target orbit radius
var decay = Math.exp(-(d * d) / (orbR * orbR)); // Gaussian decay — strong near the center, free further out
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 ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];
Each bird carries its own orbBias, so the Gaussian decay places some orbits tight against the feeding spot and others wide, spreading the whole flock across a range of rings instead of one ring.
The three-vector core rides along at reduced weight
function flockForce(idx, cx0, cy0) {
var p = boids[idx], 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];
}
A 0.08 trail overlay draws the orbits as long arcs
var TRAIL = 0.08; // trail overlay alpha — lower means longer visible arcs
var damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;
08Glowing plankton — a swarm shining in the night sea
Drawing each agent as a glow dot composited with lighter — so overlaps brighten — and letting them drift on a deep-blue sea gives the bioluminescent bloom that spreads from every oar-stroke, measured at 32.674% cumulative area over 23 of 24 moving frames.

<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 glowing plankton drift across a night sea — bring the pointer close and the glow scatters and flees</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Glowing-plankton night-sea test panel" 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>plankton · lighter glow blend</span>
<span class="cd__meta cd__meta--dim">drawImage sprite · 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 · Glowing plankton — a swarm shining in the night sea — Measured area 32.674% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One glow sprite is baked once, then stamped repeatedly
var GLOW_R = 7; // glow radius (px)
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);
})();
Baking one radial-gradient sprite ahead of time and stamping a tinted copy per plankton is far cheaper than redrawing a gradient for every one of them, every frame.
Lighter compositing turns overlaps into light
ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
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.globalAlpha = 0.55 + 0.35 * pulse;
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
}
Drift and flee layer onto a loose school
var NEI_R = 30, SEP_R = 12;
var W_SEP = 14, W_ALI = 5, W_COH = 2.4;
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3; // a slight upward drift
var ax = fleeX + ff[0] + driftX;
var ay = fleeY + ff[1] + driftY;
09A flock that spells — a sentence assembling while dodging the pointer
Assigning each bird a pixel of the text as its target and adding the flee vector only near the pointer gives a swarm that scatters when you barge in and re-forms the sentence when you step aside, measured at 31.423% cumulative area over 23 of 24 moving frames.

<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>A sentence itself is the set of target points — sweep through the letters with the pointer (or arrow keys) and the flock scatters, then reassembles into BOIDS when you step away</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Letter-forming flock test panel — use arrow keys to sweep through the letters" 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>target points · spring reassembly</span>
<span class="cd__meta cd__meta--dim">getImageData sampling · fillText offscreen · 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 · A flock that spells — a sentence assembling while dodging the pointer — Measured area 31.423% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Target points are sampled from an offscreen fillText
var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4; // pixel sample spacing
octx.font = FONT_STACK;
octx.fillText('BOIDS', 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 });
}
}
}
Reading getImageData back from an offscreen canvas that only drew the word turns every bright pixel of a letter into one candidate target point for a bird.
Target spring and flee vector pull against each other
var SPRING = 6.0; // target spring strength — kept low so scatter takes time to recover
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING;
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;
}
A 0.07 trail overlay keeps the scattering paths visible
var TRAIL = 0.07; // trail overlay alpha — leaves the scatter path readable
var jitX = Math.sin(tSec * 6.3 + p.ph) * 34;
var jitY = Math.cos(tSec * 5.1 + p.ph * 1.3) * 30;
Where a flocking demo actually earns its place
An intro where a logo or a brand line scatters and reassembles fits 09's flock that spells — it dodges the pointer and folds back into the sentence, which is exactly the kind of motion that holds a header area's attention. On a dark background, like a card hover or a loading overlay, 08's glowing plankton leaves only light behind, light enough not to compete with whatever sits on top.
For a scrolling background section, 06's murmuration — a black cloud trailing flow lines — or 02's bird silhouettes, drifting out of phase with each other, both work as a quiet repeat; either one belongs only in the margin that never overlaps text.
Where the flock breaks once the neighbor radius grows too wide
The first thing to break is the balance between separation and cohesion. Push cohesion too hard and the flock collapses toward a point, then separation shoves everyone back out, and the whole thing oscillates instead of settling. Building 03's fish school, a heavier cohesion weight made the school coil up and stall — dropping it to 0.6 is the exact value that keeps the nhgr9myq school moving like water instead of freezing into a knot. Second, 06's curl-noise finite-difference step e sits at 1.5px because the underlying potential is a smooth sum of sines that never turns sharply — push e much wider and the sampled gradient stops tracking the surface, so the wind snaps direction instead of curving. Third, shrinking 05's cell size (48px) below its own perception radius (30px) means a boid's true neighbors can sit outside the 3x3 search window, and the flock visibly splits down invisible seams. Last, 09 assigns targets by index modulo (i % targets.length), so tightening sampleGap past the point where target points outnumber birds leaves some letters permanently thin, since no boid ever gets assigned that leftover pixel.
Three neighbor-count variants and their reduced-motion fallback
One constant, three variants
| Variant | Changed value | What it feels like |
|---|---|---|
| Agent count | 01 three-rule flock 260 → 640 | The flock reads denser, at the cost of a heavier frame |
| Cohesion weight | 03 fish school cohesion 0.6 → 1.2 | The school packs tighter but swims more sluggishly |
| Color | $flock-ink → $flock-lilac |
The black bird silhouettes shift into a lilac-toned wave |
All three variants move exactly one line in the constants block at the top of the source — agent count is N_B (260), cohesion is W_COH (0.6), and color is item 02's TONE switch flipping from 0 to 1.
A still frame for reduced-motion visitors
When prefers-reduced-motion: reduce is set, none of the nine scenes starts its requestAnimationFrame loop — each one instead pre-simulates a handful of steps and draws a single settled frame, using STILL_T at 1.6 seconds for 01–03, the moment the predator strikes for 04, and 3.0 seconds into the wind-drawn trail for 06. The simulation math still runs exactly as it would in motion, only the drawing happens once, so the shape of the flock still comes through.
Five scenes take pointer and keyboard input
All nine scenes carry role="application" and tabindex="0", so tab focus always reaches the demo; in 04, 05, 07, 08 and 09 a keydown handler goes further, letting both pointer and keyboard steer the extra force each one adds — 04 steers the predator with arrow keys, 07 maps horizontal position to swirl rotation and vertical position to pull strength, and 09 scatters the sentence with the arrow keys as well. A keyboard visitor who has reduced motion turned on still gets a single recomputed frame the instant a key is pressed.
Questions people ask before wiring up a flocking simulation
Can I paste this boids algorithm javascript code in and use it as-is?
Yes — each code block above comes straight from the running demo's own source rather than being retyped, and the file behind the password zip on this page has no external dependency to wire up. Copy one item's constants block, its step function, and its draw function into a page with a canvas element, and it runs.
Can a CSS flocking animation replace this?
A handful of birds following a fixed path can be done in CSS keyframes, but a flock that reads its own neighbors and splits open around a predator has to recompute the space between every pair of agents each frame, which keyframes simply can't express. Readers who land here searching flocking birds simulation are almost always after that reactive kind — the shape that only per-frame calculation can produce.
How many agents can this handle before it slows down?
An all-pairs comparison scales with the square of the agent count, so 300–400 is roughly the practical ceiling before frames start dropping. Item 05's spatial hash instead checks only a 3x3 window of grid cells per agent, which is why it runs 1000 birds at once — keep the cell size above the perception radius and the same trick scales past 2000.
Sources: MDN — CanvasRenderingContext2D · MDN — Window.requestAnimationFrame() · MDN — prefers-reduced-motion · Craig Reynolds — Boids, the original page · Wikipedia — Boids · a swarm that walks toward sampled text pixels, without a flee force · a cell-to-cell diffusion field driving fluid instead of birds.









