07餌を中心に — 引力の周りを回る軌道
群れ全体に弱い引力点を1つ置き、個体の速度上限を下げると、餌の周りをゆっくり回る鳥たちの渦になります。
使いどころ瞑想・集中ページのゆったりした背景
createRadialGradientfillRectrequestAnimationFrame

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


