銀河 シミュレーション 9種 — ぶつかり合う星が渦巻腕と潮汐尾を作る
ライブラリ・WebGLなしで、星ひとつひとつに毎フレームニュートン重力を積分すると、2つの銀河がすれ違い潮汐尾を残して合体します。宇宙規模の引力がそのまま目の前で起こるシーンをコピペで持ち帰れます。
01 galaxy-nbody-galaxy-collide
02 galaxy-nbody-spiral-arms-form
03 galaxy-nbody-cluster-orbit
04 galaxy-nbody-dark-matter-halo
05 galaxy-nbody-merger-remnant
06 galaxy-nbody-tidal-tail
07 galaxy-nbody-launch-comet
08 galaxy-nbody-trail-history
09 galaxy-nbody-deep-field-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 2つの銀河衝突 — すれ違い潮汐尾を残して合体する基本シーン
- 02 渦巻き銀河の形成 — 円盤の星が自然に腕を作る過程
- 03 球状星団の公転 — 数百の星が共通重心を回る軌道
- 04 ダークマターハロー — 見えない質量を入れると軌道が変わる実験
- 05 合体残骸 — 渦巻きが砕けて丸くなる結末
- 06 潮汐尾のクローズアップ — ある1星の軌跡で見る放出
- 07 彗星を撃つ — ドラッグの角度と強さで星を投げ込む
- 08 軌跡ヒストリー — 残像で積もる星の過去
- 09 ディープフィールドシーン — 何億年も込めた銀河進化の舞台
デモ
九つの盤はそれぞれ index.html と style.css の2ファイルで自立し、開いた瞬間から動きます。24フレームの録画区間で九つの盤すべてが23フレーム動き続け、星の位置は毎フレーム最初から再計算されるので、画面のどの星も止まりません。07番はドラッグで星を投げ込め、04番は見えない質量のトグルを押せるので、通り過ぎてしまう前に一度触ってみてください。
| 項目 | 焦点 | 累積面積(%) | 強度平均 |
|---|---|---|---|
| 01 すれ違いと尾 | 2つの円盤がすれ違い尾を吐く基本場面 | 20.125 | 59.3 |
| 02 腕の成長 | 差動回転が円盤を渦巻きへ巻き上げる過程 | 31.31 | 48.0 |
| 03 重心の周回 | 球状星団がそれぞれの軌道面で回る形 | 22.827 | 49.4 |
| 04 見えない質量 | ハローのトグルで変わる外側の回転速度 | 37.479 | 50.5 |
| 05 二度目の接近以降 | 渦巻き腕がほどけて楕円体へ固まる結末 | 32.382 | 48.4 |
| 06 一つの星の軌跡 | 尾を作る個別軌道の往復 | 20.762 | 55.0 |
| 07 ドラッグ発射 | 角度と強さを手で決めて彗星を投入 | 26.731 | 55.3 |
| 08 積もる残像 | 消されない軌道が描く渦の文様 | 32.485 | 62.6 |
| 09 深宇宙の舞台 | 3つの銀河が順番にすれ違う締めの大画面 | 30.189 | 52.2 |
累積面積が最も広いのは04の37.479%で、最も狭いのは01の20.125%です。01・03・06・07は星の点そのものが画面の狭い面積しか占めない主題なので20.125〜26.731%の帯に留まりますが、これは場面の性質であって計算の量ではありません。フレーム変化の強度は08が62.6で最も明るく、04の右上のグラフに描かれる平らな回転曲線は、銀河の外側の星が予想より速く回るという観測そのものです。
衝突の前から後まで、時間軸に並べた九つの場面
並び順の基準は見栄えではなく、引力の相手がどこにいるかです。01は2つの円盤が互いの星を引き合うすれ違いの基本で、02は描かない中心質量の引力と星同士の重力を与えた静止円盤が自分で腕を育てる過程、03は円盤ではなく球に撒いた星が重心の周りをそれぞれの軌道面で回る対照例です。04は九つの中で唯一、星同士の直接計算を半径の格子に置き換える近似の盤で、05は一度すれ違った後の2回目から始めて合体の結末を見せ、06は340個の星の中の1個の軌跡だけを太く残します。07で初めて手で星を投げ、08で残像を消さずに積み、09が星雲の舞台で締めくくります。星自身が引力の源である点では、すれ違う個体の位置だけを見るボイドや、濃度の勾配だけを頼りに道を伸ばす粘菌の9盤と同種の面白さです。
銀河 衝突 シミュレーション が扱いにくい理由
銀河 衝突 シミュレーション が難しいのは、衝突が1回の出来事ではなく、何億年もかけてすれ違いと引きちぎりを繰り返す過程だからです。1枚の絵として描くこともできますが、この記事では星ごとの軌道積分から構造を再現するので、近づく速さとすれ違いの距離を決めるだけで尾の形が変わります。
項目別コード
項目ごとに、挙動を決める定数名を示し、動いている関数をそのまま引用します。切り詰めた断片ではなく動いているコードそのものなので、この画面から自分のページへ貼って動かせます。コメントだけこの記事の言葉に置き換えてあり、変数名と関数名はzipの中身と同じ英語のままです。
012つの銀河衝突 — すれ違い潮汐尾を残して合体する基本シーン
星340個を2つの円盤に播種し、円盤の縁が円軌道速度になるよう中心質量 M = 0.90·R² を与えて、反対側から接近させます。すれ違う瞬間に外側の星が2つの中心の間を行き来してエネルギーを失い、外へ放り出される潮汐尾がひとりでに育ち、全ペアを直接計算する近似なしの盤です。2.0秒のループでフレーム最大16.048%は盤の中でも大きいほうであり、1フレームごとの変化が激しいことを示しています。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>各星に毎フレームニュートン重力を足して速度を更新すると、すれ違いの瞬間、外の星が放出される潮汐尾がひとりでに生まれます</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="2銀河衝突ラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">星340個・O(N²) 全ペア計算</span>
<span class="gx__meta gx__meta--dim">canvas · Newton gravity · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 두 은하 충돌(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(별 2N 개 상호 중력 O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 두 은하 충돌(01) — n체 중력의 기본 장면.
// 별마다 (x, y, vx, vy) 를 두고 매 프레임 모든 쌍의 중력 가속도를 더한다(전 쌍 O(N²) —
// 근사 아님. N=340 이라 57,630 쌍/프레임, 데모 크기에서 60fps 가 유지된다).
// 적분은 semi-implicit Euler(속도 갱신 뒤 위치 갱신) — 누적 오차로 궤도가 서서히 부풀지만
// 2초 루프 안에서는 안정하다. (02 의 leapfrog 이 에너지를 더 오래 보존한다.)
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 340; // 두 은하 합계 별 수 — 변형 "별 개수"가 340→120으로
var SKIP = 0.60; // 스치는 거리(원반 반지름의 배) — 변형 "근접 시 천체항"이 0.6→0.25로
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx; // 궤적 잔상용 투명 오프스크린 캔버스
// 별 상태 — Float64Array 가 아니라 짝수 인덱스 x/y·홀수 vx/vy 쌍 배열(가독성 우선)
var N = 0, X = [], Y = [], VX = [], VY = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function seed() {
// 두 원반 은하를 좌우 대각으로 놓고 서로를 향해 살짝 비스듬히 움직이게 한다.
// 원반 별: 반지름 r 에 비례하는 원궤도 속도 + 작은 랜덤 흔들림(시드 고정 — 결정론 렌더).
X = []; Y = []; VX = []; VY = []; N = 0;
var rnd = mulberry32(7);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.40; // 원반 반지름 — 화면 절반을 쓴다(누적면적)
var sc = R; // 속도 스케일 — 원반 크기에 연동
placeDisk(W * 0.30, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc); // 은하 A — 오른쪽 위로
placeDisk(W * 0.70, H * 0.66, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc); // 은하 B — 왼쪽 아래로
}
// cx,cy 중심 원반. dvx,dvy 은하 자신의 이동 속도(원반 반지름 R 배율). dir 공전 방향(+1 시계반대).
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
// 중심 질량 M — 원반 가장자리(r=R)에서 원궤도 속도 v=sqrt(M/R) 가 나오게 잡는다
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd()); // 면밀도 균일 — r² 균등
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
N++;
}
// 은하 중심 핵 별 1개(질량 큰 것은 크기로 표현 — 배열은 동일, 질량은 상수라 별도 관리 안 함)
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 단위를 캔버스 픽셀에 맞춘 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 140.0, SOFT2 = 16; // softening ε²=16 — 근접 발산 방지(뉴턴 중력의 숫자 안정 장치)
var DT = 1 / 60;
function step() {
// 전 쌍 중력 — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5. 대칭이라 쌍당 한 번 계산해 양쪽에 반영
var AX = new Array(N), AY = new Array(N);
for (var i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = X[j] - X[i], dy = Y[j] - Y[i];
var d2 = dx * dx + dy * dy + SOFT2;
var inv = 1 / Math.sqrt(d2);
var f = G * inv * inv * inv; // f = G / d³ — dx·f 가 가속도 성분
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
// 잔상 감쇠 — 궤적 캔버스를 매 프레임 조금씩 지운다(조석 꼬리가 궤도 누적으로 보이게)
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.016)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
// 별을 잔상 캔버스에 1px 점으로 찍는다 — 이전 프레임과 이어 그리면 궤도 선이 된다
if (prev) {
for (var i = 0; i < N; i++) {
chainCtx.strokeStyle = rgba(i < (N >> 1) ? C_GLOW : C_TAIL, 0.32);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 — 관측창 바닥색으로 매 프레임 지운다(별이 아니라 잔상 캔버스가 궤적을 기억)
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적) — 깊이감. 시드 고정이라 렌더마다 같다
var rnd = mulberry32(99);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하 A 파스텔 페리윙클 · 은하 B 파스텔 핑크. 코어 근처 별은 밝게
for (var i = 0; i < N; i++) {
var col = i < (N >> 1) ? C_GLOW : C_TAIL;
var d = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var near = Math.max(0, 1 - d / (Math.min(W, H) * 0.72));
// 글로우 원(반지름 3.8) + 코어 점(반지름 1.7) 두 겹 — 앰비언트 글로우로 면적 확보
ctx.fillStyle = rgba(col, 0.10 + near * 0.10);
ctx.beginPath();
ctx.arc(X[i], Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.55 + near * 0.45);
ctx.beginPath();
ctx.arc(X[i], Y[i], 1.7 + near * 1.0, 0, Math.PI * 2);
ctx.fill();
}
// 은하 B 코어 표식 — 앰버 점(중심 별이 아니라 코어 위치 추적 마커)
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(X[N >> 1], Y[N >> 1], 2.2, 0, Math.PI * 2); ctx.fill();
}
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
// 2초 루프 — 설정 초(2s)의 약수 주기로 시뮬을 되감는다(결정론 렌더·무한 반복)
var loop = ((now - t0) / 1000) % 2.0;
var target = Math.floor(loop / DT);
while (simSteps < target) { step(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 루프 중반(꼬리가 가장 그럴듯한) 장면까지 시뮬레이션한 뒤 한 장을 고정한다
for (var s = 0; s < 150; s++) { step(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyGalaxyCollide — vanilla 판(run/322/demo/01-galaxy-nbody-galaxy-collide/index.html) 의 React 포트.
// 두 은하의 별 각각에 매 프레임 뉴턴 중력을 더해 속도를 갱신하면, 스치는 순간 바깥 별이
// 뿜겨 나가는 조석 꼬리가 저절로 생긴다. 별마다 (x, y, vx, vy) 를 두고 매 프레임 모든 쌍의
// 중력 가속도를 더한다(전 쌍 O(N²) — 근사 아님. N=340 이라 57,630 쌍/프레임).
// 적분은 semi-implicit Euler(속도 갱신 뒤 위치 갱신) — 2초 루프 안에서 안정하다.
// 궤적은 투명 누적 캔버스에 찍힌 뒤 destination-out 로 조금씩 지워진다(조성 꼬리가 궤도 누적으로 보인다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyGalaxyCollide.module.scss";
export type GalaxyNbodyGalaxyCollideProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 두 은하 합계 별 수 — 변형 "별 개수"가 340→120으로
};
// ── 변형 상수(기본값) — vanilla 판과 동일. 스침 거리 SKIP 은 변형 제작용 표식(기본 장면 미사용) ──
const SKIP = 0.60;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "두 은하 충돌 실험판";
// G·질량은 캔버스 픽셀에 맞춘 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 140.0, SOFT2 = 16; // softening ε²=16 — 근접 발산 방지(뉴턴 중력의 숫자 안정 장치)
const DT = 1 / 60;
// 결정론 시더 — 시드 고정이라 렌더마다 같은 장면이 나온다
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyGalaxyCollide({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 340
}: GalaxyNbodyGalaxyCollideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
// 별 상태 — 짝수 인덱스 x/y·홀수 vx/vy 쌍 배열(가독성 우선 — vanilla 판과 동일 구조)
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
// cx,cy 중심 원반. dvx,dvy 은하 자신의 이동 속도(원반 반지름 R 배율). dir 공전 방향(+1 시계반대).
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, rnd: () => number, sc: number): void {
const sim = simRef.current;
// 중심 질량 M — 원반 가장자리(r=R)에서 원궤도 속도 v=sqrt(M/R) 가 나오게 잡는다
const M = R * R * 0.90;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd()); // 면밀도 균일 — r² 균등
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.N++;
}
}
function seed(): void {
// 두 원반 은하를 좌우 대각으로 놓고 서로를 향해 살짝 비스듬히 움직이게 한다.
// 원반 별: 반지름 r 에 비례하는 원궤도 속도 + 작은 랜덤 흔들림(시드 고정 — 결정론 렌더).
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
const rnd = mulberry32(7);
const half = nstarsRef.current >> 1;
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.40; // 원반 반지름 — 화면 절반을 쓴다(누적면적)
const sc = R;
placeDisk(W * 0.30, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc); // 은하 A — 오른쪽 위로
placeDisk(W * 0.70, H * 0.66, R, nstarsRef.current - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc); // 은하 B — 왼쪽 아래로
}
function step(): void {
// 전 쌍 중력 — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5. 대칭이라 쌍당 한 번 계산해 양쪽에 반영
const sim = simRef.current;
const N = sim.N;
const AX = new Array<number>(N), AY = new Array<number>(N);
for (let i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
const dx = sim.X[j] - sim.X[i], dy = sim.Y[j] - sim.Y[i];
const d2 = dx * dx + dy * dy + SOFT2;
const inv = 1 / Math.sqrt(d2);
const f = G * inv * inv * inv; // f = G / d³ — dx·f 가 가속도 성분
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
for (let i = 0; i < N; i++) {
sim.VX[i] += AX[i] * DT; sim.VY[i] += AY[i] * DT;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
}
function stamp(): void {
// 별을 잔상 캔버스에 1px 점으로 찍는다 — 이전 프레임과 이어 그리면 궤도 선이 된다
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
chainCtx.strokeStyle = rgba(i < (sim.N >> 1) ? col.glow : col.tail, 0.32);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function decay(): void {
// 잔상 감쇠 — 궤적 캔버스를 매 프레임 조금씩 지운다(조석 꼬리가 궤도 누적으로 보이게)
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.016)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 — 관측창 바닥색으로 매 프레임 지운다(별이 아니라 잔상 캔버스가 궤적을 기억)
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적) — 깊이감. 시드 고정이라 렌더마다 같다
const rnd = mulberry32(99);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하 A 파스텔 페리윙클 · 은하 B 파스텔 핑크. 코어 근처 별은 밝게
for (let i = 0; i < sim.N; i++) {
const c = i < (sim.N >> 1) ? col.glow : col.tail;
const d = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const near = Math.max(0, 1 - d / (Math.min(W, H) * 0.72));
// 글로우 원(반지름 3.8) + 코어 점(반지름 1.7) 두 겹 — 앰비언트 글로우로 면적 확보
ctx.fillStyle = rgba(c, 0.10 + near * 0.10);
ctx.beginPath();
ctx.arc(sim.X[i], sim.Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.55 + near * 0.45);
ctx.beginPath();
ctx.arc(sim.X[i], sim.Y[i], 1.7 + near * 1.0, 0, Math.PI * 2);
ctx.fill();
}
// 은하 B 코어 표식 — 앰버 점(중심 별이 아니라 코어 위치 추적 마커)
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(sim.X[sim.N >> 1], sim.Y[sim.N >> 1], 2.2, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 루프 중반(꼬리가 가장 그럴듯한) 장면까지 시뮬레이션한 뒤 한 장을 고정한다
for (let s = 0; s < 150; s++) { step(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
// 2초 루프 — 설정 초(2s)의 약수 주기로 시뮬을 되감는다(결정론 렌더·무한 반복)
const loop = ((now - t0) / 1000) % 2.0;
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { step(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>두 은하의 별 각각에 매 프레임 뉴턴 중력을 더해 속도를 갱신하면, 스치는 순간 바깥 별이 뿜겨 나가는 조석 꼬리가 저절로 생깁니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 340개 · O(N²) 전 쌍 계산</span>
<span className={styles.metaDim}>canvas · Newton gravity · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 2つの銀河衝突 — すれ違い潮汐尾を残して合体する基本シーン — 計測 累積面積 20.125% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
全ペア重力とsemi-implicit Eulerの最小構成
重力は a_i = Σ_j G·(x_j − x_i)/(|d|²+ε²)^1.5 で、ペアの対称性を使って1ペア1回の計算で両側に反映します。G=140、ε²=16、DT=1/60で、速度を更新してから位置を動かすsemi-implicit Eulerです。
function step() {
// 全ペア重力 — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5。対称なのでペアごとに1回計算して両側に反映
var AX = new Array(N), AY = new Array(N);
for (var i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = X[j] - X[i], dy = Y[j] - Y[i];
var d2 = dx * dx + dy * dy + SOFT2;
var inv = 1 / Math.sqrt(d2);
var f = G * inv * inv * inv; // f = G / d³ — dx·f が加速度成分
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
}
円盤の縁が円軌道速度になる播種
円盤の中心質量は R² に比例する値を置き、縁の星が円軌道に乗る速度 v = √(M/r) を星ごとに与えます。この盤の累積面積20.125%は最も狭い数値ですが、尾が一瞬で大きく伸びる場面の性格そのものです。
02渦巻き銀河の形成 — 円盤の星が自然に腕を作る過程
静止円盤に描かない中心質量 MHALO=6200 の引力と星同士の重力を足し、積分器をleapfrogにすると、内側が速く回る差動回転が初期の密度ゆらぎを巻き上げて渦巻き腕が明瞭になります。METHOD定数を'euler'に変える変形比較の土台でもあり、3.0秒ループでフレーム最大24.639%は巻き戻しの瞬間に残像が一斉にずれる区間の値です。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>内側が速く回る差動回転が初期ゆらぎを増幅し、静止円盤が積分だけで渦巻き腕を育てます</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="渦巻き腕形成ラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">星320個・蛙飛び積分</span>
<span class="gx__meta gx__meta--dim">canvas · leapfrog · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나선 팔 형성(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 + 별 상호 중력 O(N²) 레인포드 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 나선 팔 형성(02) — 정지 원반이 차등회전으로 팔을 자라게 하는 장면.
// 01 과 같은 전 쌍 O(N²) 뉴턴 중력이되 적분기를 leapfrog(개구리뛰기)로 바꾼다:
// 가속도를 절반 스텝 앞뒤로 대칭 계산해 속도·위치를 번갈아 밀면 에너지가 오래 보존되어
// 원반이 몇 바퀴을 돌아도 인위적으로 부풀지 않는다(변형 "적분기"의 본체).
// 여기에 중심 질량(그리지 않는 핵)을 크게 두고 별 상호 중력을 더하면
// 안쪽이 빠르게 도는 차등회전이 초기 밀도 요동을 감아 올려 나선 팔이 저절로 선명해진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 320; // 원반 별 수
var METHOD = 'leapfrog'; // 'euler' 로 바꾸면 변형 "적분기: 오일러"가 된다
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [], MASS = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// G·질량은 캔버스 픽셀 단위의 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 36;
var DT = 1 / 60;
var MHALO = 6200; // 중심 질량(핵+헤일로 합) — 그리지 않는다. 별 상호중력과 함께 가속도에 더해진다
// 가속도 합산 — 중심 질량 인력 + 별끼리의 전 쌍 O(N²). AX/AY 는 재사용 버퍼(가비지 최소화)
var AX = [], AY = [];
function accel() {
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
// 중심 질량 — 원점이 아니라 원반 중심을 향한다(원반이 화면 중앙에 있으므로 W/2, H/2)
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
// 별 상호 중력 — 대칭이라 쌍당 한 번 계산해 양쪽에 반영
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MASS[i] * MASS[j] * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepEuler() {
// semi-implicit Euler(변형 비교용 — 기본은 leapfrog)
accel();
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
}
var aReady = false;
function stepLeap() {
// leapfrog(속도_verlet) — a(t) 를 재사용해 절반씩 대칭으로 밀어 에너지를 보존한다
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h; // v(t+h/2) = v(t) + a(t)·h/2
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; // x(t+h) = x(t) + v(t+h/2)·h
}
accel(); // a(t+h)
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h; // v(t+h) = v(t+h/2) + a(t+h)·h/2
}
}
function step() { if (METHOD === 'leapfrog') stepLeap(); else stepEuler(); }
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seed() {
// 정지 원반 — 반지름별 원궤도 속도(안쪽이 빠르다 = 차등회전)에 초기 밀도 요동(이빨)을 심는다.
// 요동은 각도 방향 사인파 2개 — 시간이 흐르면 차등회전이 이를 감아 나선으로 만든다.
X = []; Y = []; VX = []; VY = []; MASS = []; N = 0;
var rnd = mulberry32(11);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.44;
for (var i = 0; i < NSTARS; i++) {
var far = rnd() < 0.50; // 바깥 별 — 원반 밖 넓은 궤도(외곽 궤적 누적)
var r = far
? R * (0.42 + 2.00 * rnd())
: 0.12 * R + R * 0.82 * Math.sqrt(rnd()); // 코어 밀집 + 바깥 얇게
var a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 각도에 반지름 결합 사인 요동
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.82 : 1.0); // 원궤도 속도 — r 이 작을수록 크다
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.90); // 살짝 눌러 타원 원반(시각적 깊이)
VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
VY.push( Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.5);
MASS.push(0.5 + rnd() * 0.8);
N++;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.015)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
// 잔상 — 이전 프레임과 이어 그리면 궤도 누적으로 팔이 선명해진다
if (prev) {
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < Math.min(W, H) * 0.18 ? C_HOT : C_GLOW;
chainCtx.strokeStyle = rgba(col, 0.28);
chainCtx.lineWidth = 1.8;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 별밭(정적·시드 고정) — 깊이감
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(21);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H;
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillRect(bx, by, 1.2, 1.2); // fillRect 점 — 원반이라 arc 점보다 빠르다
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 바깥 별은 파스텔 페리윙클 사각 점(밀도가 눈에 잘 들어온다), 코어는 파스텔 앰버
var R = Math.min(W, H);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < R * 0.16 ? C_HOT : C_GLOW;
var sz = r < R * 0.16 ? 3.0 : 2.6;
ctx.globalAlpha = 0.85;
ctx.fillStyle = rgba(col, 0.16); // 글로우 겉층
ctx.fillRect(X[i] - sz, Y[i] - sz, sz * 2, sz * 2);
ctx.fillStyle = rgba(col, 0.95); // 코어
ctx.fillRect(X[i] - sz * 0.40, Y[i] - sz * 0.40, sz * 0.80, sz * 0.80);
}
ctx.globalAlpha = 1;
// 중심 질량 위치 표식 — 그리지 않는 핵의 위치(원반 중심)를 앰버 점으로만 알린다
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.fillRect(W * 0.5 - 1.8, H * 0.5 - 1.8, 3.6, 3.6);
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 팔이 자라는 초반을 반복 보여준다
var target = Math.floor(loop / DT);
while (simSteps < target) { step(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 팔이 어느 정도 자란 장면까지 적분한 뒤 한 장을 고정한다
for (var s = 0; s < 240; s++) { step(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodySpiralArmsForm — vanilla 판(run/322/demo/02-galaxy-nbody-spiral-arms-form/index.html) 의 React 포트.
// 정지 원반이 차등회전으로 팔을 자라게 하는 장면. 01 과 같은 전 쌍 O(N²) 뉴턴 중력이되
// 적분기를 leapfrog(개구리뛰기)로 바꾼다: 가속도를 절반 스텝 앞뒤로 대칭 계산해
// 속도·위치를 번갈아 밀면 에너지가 오래 보존되어 원반이 몇 바퀴을 돌아도 부풀지 않는다.
// 중심 질량(그리지 않는 핵)을 크게 두고 별 상호 중력을 더하면 안쪽이 빠르게 도는
// 차등회전이 초기 밀도 요동을 감아 올려 나선 팔이 저절로 선명해진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodySpiralArmsForm.module.scss";
export type GalaxyNbodySpiralArmsFormProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
method?: "leapfrog" | "euler"; // 적분기 — 'euler' 로 바꾸면 변형 "적분기: 오일러"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나선 팔 형성 실험판";
// G·질량은 캔버스 픽셀 단위의 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 36;
const DT = 1 / 60;
const MHALO = 6200; // 중심 질량(핵+헤일로 합) — 그리지 않는다. 별 상호중력과 함께 가속도에 더해진다
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodySpiralArmsForm({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 320,
method = "leapfrog"
}: GalaxyNbodySpiralArmsFormProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const methodRef = useRef(method) as Box<string>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
type SimM = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[]; MASS: number[] };
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[], MASS: [] as number[] }) as Box<SimM>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false);
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
// 가속도 합산 — 중심 질량 인력 + 별끼리의 전 쌍 O(N²). AX/AY 는 재사용 버퍼(가비지 최소화)
function accel(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
const W = wRef.current, H = hRef.current;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
// 중심 질량 — 원점이 아니라 원반 중심을 향한다(원반이 화면 중앙에 있으므로 W/2, H/2)
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - sim.X[i]; dy = cy - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
// 별 상호 중력 — 대칭이라 쌍당 한 번 계산해 양쪽에 반영
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * sim.MASS[i] * sim.MASS[j] * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepEuler(): void {
// semi-implicit Euler(변형 비교용 — 기본은 leapfrog)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
accel();
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * DT; sim.VY[i] += AY[i] * DT;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
}
function stepLeap(): void {
// leapfrog(속도_verlet) — a(t) 를 재사용해 절반씩 대칭으로 밀어 에너지를 보존한다
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h; // v(t+h/2) = v(t) + a(t)·h/2
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT; // x(t+h) = x(t) + v(t+h/2)·h
}
accel(); // a(t+h)
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h; // v(t+h) = v(t+h/2) + a(t+h)·h/2
}
}
function step(): void {
if (methodRef.current === "leapfrog") stepLeap();
else stepEuler();
}
function seed(): void {
// 정지 원반 — 반지름별 원궤도 속도(안쪽이 빠르다 = 차등회전)에 초기 밀도 요동(이빨)을 심는다.
// 요동은 각도 방향 사인파 — 시간이 흐르면 차등회전이 이를 감아 나선으로 만든다.
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.MASS = []; sim.N = 0;
const rnd = mulberry32(11);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.44;
for (let i = 0; i < nstarsRef.current; i++) {
const far = rnd() < 0.50; // 바깥 별 — 원반 밖 넓은 궤도(외곽 궤적 누적)
const r = far
? R * (0.42 + 2.00 * rnd())
: 0.12 * R + R * 0.82 * Math.sqrt(rnd()); // 코어 밀집 + 바깥 얇게
const a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 각도에 반지름 결합 사인 요동
const v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.82 : 1.0); // 원궤도 속도 — r 이 작을수록 크다
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.90); // 살짝 눌러 타원 원반(시각적 깊이)
sim.VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
sim.VY.push(Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.5);
sim.MASS.push(0.5 + rnd() * 0.8);
sim.N++;
}
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.015)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
// 잔상 — 이전 프레임과 이어 그리면 궤도 누적으로 팔이 선명해진다
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < Math.min(W, H) * 0.18 ? col.hot : col.glow;
chainCtx.strokeStyle = rgba(c, 0.28);
chainCtx.lineWidth = 1.8;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 별밭(정적·시드 고정) — 깊이감
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(21);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H;
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillRect(bx, by, 1.2, 1.2); // fillRect 점 — 원반이라 arc 점보다 빠르다
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 바깥 별은 파스텔 페리윙클 사각 점(밀도가 눈에 잘 들어온다), 코어는 파스텔 앰버
const R = Math.min(W, H);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < R * 0.16 ? col.hot : col.glow;
const sz = r < R * 0.16 ? 3.0 : 2.6;
ctx.globalAlpha = 0.85;
ctx.fillStyle = rgba(c, 0.16); // 글로우 겉층
ctx.fillRect(sim.X[i] - sz, sim.Y[i] - sz, sz * 2, sz * 2);
ctx.fillStyle = rgba(c, 0.95); // 코어
ctx.fillRect(sim.X[i] - sz * 0.40, sim.Y[i] - sz * 0.40, sz * 0.80, sz * 0.80);
}
ctx.globalAlpha = 1;
// 중심 질량 위치 표식 — 그리지 않는 핵의 위치(원반 중심)를 앰버 점으로만 알린다
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.fillRect(W * 0.5 - 1.8, H * 0.5 - 1.8, 3.6, 3.6);
}
function stillFrame(): void {
// reduce — 팔이 어느 정도 자란 장면까지 적분한 뒤 한 장을 고정한다
for (let s = 0; s < 240; s++) { step(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 팔이 자라는 초반을 반복 보여준다
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { step(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
methodRef.current = method;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
aReadyRef.current = false;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, method]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>안쪽이 빠르게 도는 차등회전이 초기 밀도 요동을 늘려, 정지 원반이 적분만으로 나선팔을 자라게 합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 320개 · 레인포드 적분</span>
<span className={styles.metaDim}>canvas · leapfrog · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · 渦巻き銀河の形成 — 円盤の星が自然に腕を作る過程 — 計測 累積面積 31.31% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
leapfrogでエネルギーを保存する
加速を前後の半ステップで対称に計算するleapfrog(速度verlet)は、円盤が何周しても膨らまない積分器です。a(t)を再利用して1ステップの加速計算を2回に抑えます。
function stepLeap() {
// leapfrog(速度verlet) — a(t) を再利用して半分ずつ対称に押し、エネルギーを保存する
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h; // v(t+h/2) = v(t) + a(t)·h/2
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; // x(t+h) = x(t) + v(t+h/2)·h
}
accel(); // a(t+h)
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h; // v(t+h) = v(t+h/2) + a(t+h)·h/2
}
}
中心質量と星の質量の区別
MHALO=6200は画面に描かれない核とハローの合計で、星それぞれの質量は0.5〜1.3の範囲の配列に収めます。銀河 形成 シミュレーション の核心は、この2種類の質量を足した引力の中で差動回転がゆらぎを増幅する点にあります。
03球状星団の公転 — 数百の星が共通重心を回る軌道
円盤ではなく球面に撒いた星280個がどう回るかを見る盤で、視線方向のzも計算に入り、画面はFOCAL=420の透視投影にすぎません。角度がばらばらなので差動回転では腕ができず、星は毎フレーム再計算した重心の周りをそれぞれの軌道面で回り、星団全体がゆっくり回ります。SPIN=0.55の集団回転を与え、G=26・ε²=60で3次元の全ペアを直接計算します。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>球状に撒いた星が共通重心を各々の角度で回り、円盤と違い中心へ潰れません</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="球状星団ラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">星280個・重心を毎回計算</span>
<span class="gx__meta gx__meta--dim">canvas · center of mass · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구상 성단 공전(03). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(구면으로 흩뿌린 별 280개 무게중심 계산 + O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 구상 성단 공전(03) — 원반이 아닌 구면 분포가 어떻게 도는지 보는 장면.
// 별 280개를 3차원 구(시선 방향 z 를 원근 스케일로 투영)로 흩뿌리고 전 쌍 O(N²) 중력을 적분한다.
// 은하 원반(02)과 달리 각도가 제각각이라 차등회전이 나선을 만들지 못하고,
// 별 각각은 매 프레임 다시 계산한 무게중심(com) 주위를 제각각의 궤도면으로 돈다 —
// 성단 전체가 느리게 뒤집히는 관측 성질이 그대로 드러난다.
// z 투영은 원근(가까운 별 크게)뿐 아니라 계산도 3차원으로 한다 — 화면은 그 투영일 뿐.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 280; // 성단 별 수
var SPIN = 0.55; // 초기 집단 회전(전체 궤도면 기울기 스케일)
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
// 3차원 상태 — 화면은 (x, y) 투영. z 는 원근 스케일에만 쓰지 않고 계산에 그대로 들어간다
var N = 0, X = [], Y = [], Z = [], VX = [], VY = [], VZ = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 26.0, SOFT2 = 60;
var DT = 1 / 60;
var FOCAL = 420; // 투영 초점거리 — z 깊이에 비례해 화면 좌표를 확대/축소
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function com() {
// 무게중심 — 매 스텝 다시 계산한다(별이 날아가도 성단이 따라간다).
// 계산 자체가 데모의 주제: 중심을 고정하지 않고 합이 0이 되게 두면 성단 전체가 유영한다
var cx = 0, cy = 0, cz = 0;
for (var i = 0; i < N; i++) { cx += X[i]; cy += Y[i]; cz += Z[i]; }
return [cx / N, cy / N, cz / N];
}
function seed() {
// 구면 등밀도 흩뿌리기 + 완만한 집단 회전. 궤도 속도는 구 대신 접선 방향 랜덤 —
// 각자의 궤도면이 달라 원반처럼 정렬되지 않는다(구상 성단의 핵심 성질)
X = []; Y = []; Z = []; VX = []; VY = []; VZ = []; N = 0;
var rnd = mulberry32(33);
var R = Math.min(W, H) * 0.78;
for (var i = 0; i < NSTARS; i++) {
var u = rnd() * 2 - 1, ph = rnd() * Math.PI * 2;
var sq = Math.sqrt(1 - u * u);
var rr = R * 0.94 * Math.cbrt(rnd()); // 구 등밀도 — r³ 균등
var dx = sq * Math.cos(ph), dy = sq * Math.sin(ph), dz = u;
X.push(W * 0.5 + dx * rr);
Y.push(H * 0.5 + dy * rr * 0.92);
Z.push(dz * rr);
// 접선 속도 — 위치와 수직인 방향 중 하나를 골라 원궤도 스케일로. 궤도면 제각각
var tx = -dy, ty = dx, tz = 0;
var tl = Math.hypot(tx, ty) || 1;
var vv = Math.sqrt(G * NSTARS / Math.max(rr, 12)) * (0.55 + rnd() * 0.5);
VX.push((tx / tl) * vv + SPIN * 14 * dy + (rnd() - 0.5) * 6);
VY.push((ty / tl) * vv + SPIN * 14 * dx + (rnd() - 0.5) * 6);
VZ.push((rnd() - 0.5) * vv * 1.2);
N++;
}
}
var AX = [], AY = [], AZ = [];
function step() {
// 전 쌍 O(N²) 3차원 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, dz, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; AZ[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i]; dz = Z[j] - Z[i];
d2 = dx * dx + dy * dy + dz * dz + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f; AZ[i] += dz * f;
AX[j] -= dx * f; AY[j] -= dy * f; AZ[j] -= dz * f;
}
}
for (i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT; VZ[i] += AZ[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; Z[i] += VZ[i] * DT;
}
}
function project(i) {
// 3D → 2D 투영. 초점거리로 원근(가까운 별이 크고 빠르다)
var s = FOCAL / (FOCAL + Z[i]);
return [W * 0.5 + (X[i] - W * 0.5) * s, H * 0.5 + (Y[i] - H * 0.5) * s, s];
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.013)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
// 잔상 — 투영 좌표로 궤도를 남긴다(궤도면이 제각각이라 곡선 뭉치가 쌓인다)
if (prev) {
for (var i = 0; i < N; i++) {
var p = project(i);
chainCtx.strokeStyle = rgba(C_GLOW, 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(p[0], p[1]);
chainCtx.stroke();
}
}
if (!prev) { prev = [[], []]; }
prev[0].length = 0; prev[1].length = 0;
for (var i = 0; i < N; i++) { var p = project(i); prev[0].push(p[0]); prev[1].push(p[1]); }
}
function draw() {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적·시드 고정)
var rnd = mulberry32(44);
for (var k = 0; k < 40; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillStyle = rgba(C_GLOW, 0.6);
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 무게중심 표식 — 매 프레임 계산한 com 을 앰버 십자로 찍는다(성단이 유영하는 게 보인다)
var c = com();
var cz = FOCAL / (FOCAL + c[2]);
var mx = W * 0.5 + (c[0] - W * 0.5) * cz, my = H * 0.5 + (c[1] - H * 0.5) * cz;
// 별 — 원근 스케일로 크기·밝기 변화. z 가 가까울수록 크고 밝다
for (var i = 0; i < N; i++) {
var p = project(i);
var s = p[2];
var col = s > 1.06 ? C_HOT : C_GLOW; // 앞쪽 별은 앰버, 뒷쪽은 페리윙클
ctx.fillStyle = rgba(col, 0.12 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], 4.0 * s, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.66 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], (1.4 + 1.1 * s) * s, 0, Math.PI * 2); ctx.fill();
}
ctx.strokeStyle = rgba(C_HOT, 0.85);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx - 5, my); ctx.lineTo(mx + 5, my);
ctx.moveTo(mx, my - 5); ctx.lineTo(mx, my + 5);
ctx.stroke();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.0;
var target = Math.floor(loop / DT);
while (simSteps < target) { step(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤도 뭉치가 쌓인 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 200; s++) { step(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyClusterOrbit — vanilla 판(run/322/demo/03-galaxy-nbody-cluster-orbit/index.html) 의 React 포트.
// 구상 성단 공전 — 원반이 아닌 구면 분포가 어떻게 도는지 보는 장면.
// 별 280개를 3차원 구(시선 방향 z 를 원근 스케일로 투영)로 흩뿌리고 전 쌍 O(N²) 중력을 적분한다.
// 은하 원반(02)과 달리 각도가 제각각이라 차등회전이 나선을 만들지 못하고,
// 별 각각은 매 프레임 다시 계산한 무게중심(com) 주위를 제각각의 궤도면으로 돈다.
// z 투영은 원근(가까운 별 크게)뿐 아니라 계산도 3차원으로 한다 — 화면은 그 투영일 뿐.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyClusterOrbit.module.scss";
export type GalaxyNbodyClusterOrbitProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 성단 별 수
spin?: number; // 초기 집단 회전(전체 궤도면 기울기 스케일)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "구상 성단 공전 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 26.0, SOFT2 = 60;
const DT = 1 / 60;
const FOCAL = 420; // 투영 초점거리 — z 깊이에 비례해 화면 좌표를 확대/축소
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyClusterOrbit({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 280,
spin = 0.55
}: GalaxyNbodyClusterOrbitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const spinRef = useRef(spin) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
// 3차원 상태 — 화면은 (x, y) 투영. z 는 원근 스케일에만 쓰지 않고 계산에 그대로 들어간다
type Sim3D = { N: number; X: number[]; Y: number[]; Z: number[]; VX: number[]; VY: number[]; VZ: number[] };
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], Z: [] as number[], VX: [] as number[], VY: [] as number[], VZ: [] as number[] }) as Box<Sim3D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const AZRef = useRef<number[]>([]) as Box<number[]>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function com(): [number, number, number] {
// 무게중심 — 매 스텝 다시 계산한다(별이 날아가도 성단이 따라간다).
// 계산 자체가 데모의 주제: 중심을 고정하지 않고 합이 0이 되게 두면 성단 전체가 유영한다
const sim = simRef.current;
let cx = 0, cy = 0, cz = 0;
for (let i = 0; i < sim.N; i++) { cx += sim.X[i]; cy += sim.Y[i]; cz += sim.Z[i]; }
return [cx / sim.N, cy / sim.N, cz / sim.N];
}
function seed(): void {
// 구면 등밀도 흩뿌리기 + 완만한 집단 회전. 궤도 속도는 구 대신 접선 방향 랜덤 —
// 각자의 궤도면이 달라 원반처럼 정렬되지 않는다(구상 성단의 핵심 성질)
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.Z = []; sim.VX = []; sim.VY = []; sim.VZ = []; sim.N = 0;
const rnd = mulberry32(33);
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.78;
for (let i = 0; i < nstarsRef.current; i++) {
const u = rnd() * 2 - 1, ph = rnd() * Math.PI * 2;
const sq = Math.sqrt(1 - u * u);
const rr = R * 0.94 * Math.cbrt(rnd()); // 구 등밀도 — r³ 균등
const dx = sq * Math.cos(ph), dy = sq * Math.sin(ph), dz = u;
sim.X.push(W * 0.5 + dx * rr);
sim.Y.push(H * 0.5 + dy * rr * 0.92);
sim.Z.push(dz * rr);
// 접선 속도 — 위치와 수직인 방향 중 하나를 골라 원궤도 스케일로. 궤도면 제각각
const tx = -dy, ty = dx, tz = 0;
const tl = Math.hypot(tx, ty) || 1;
const vv = Math.sqrt(G * nstarsRef.current / Math.max(rr, 12)) * (0.55 + rnd() * 0.5);
sim.VX.push((tx / tl) * vv + spinRef.current * 14 * dy + (rnd() - 0.5) * 6);
sim.VY.push((ty / tl) * vv + spinRef.current * 14 * dx + (rnd() - 0.5) * 6);
sim.VZ.push((rnd() - 0.5) * vv * 1.2);
sim.N++;
}
}
function step(): void {
// 전 쌍 O(N²) 3차원 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current, AZ = AZRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, dz: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; AZ[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i]; dz = sim.Z[j] - sim.Z[i];
d2 = dx * dx + dy * dy + dz * dz + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f; AZ[i] += dz * f;
AX[j] -= dx * f; AY[j] -= dy * f; AZ[j] -= dz * f;
}
}
for (i = 0; i < N; i++) {
sim.VX[i] += AX[i] * DT; sim.VY[i] += AY[i] * DT; sim.VZ[i] += AZ[i] * DT;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT; sim.Z[i] += sim.VZ[i] * DT;
}
}
function project(i: number): [number, number, number] {
// 3D → 2D 투영. 초점거리로 원근(가까운 별이 크고 빠르다)
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
const s = FOCAL / (FOCAL + sim.Z[i]);
return [W * 0.5 + (sim.X[i] - W * 0.5) * s, H * 0.5 + (sim.Y[i] - H * 0.5) * s, s];
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.013)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
// 잔상 — 투영 좌표로 궤도를 남긴다(궤도면이 제각각이라 곡선 뭉치가 쌓인다)
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const p = project(i);
chainCtx.strokeStyle = rgba(col.glow, 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(p[0], p[1]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [[], []];
const pv = prevRef.current as [number[], number[]];
pv[0].length = 0; pv[1].length = 0;
for (let i = 0; i < sim.N; i++) { const p = project(i); pv[0].push(p[0]); pv[1].push(p[1]); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적·시드 고정)
const rnd = mulberry32(44);
for (let k = 0; k < 40; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillStyle = rgba(col.glow, 0.6);
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 무게중심 표식 — 매 프레임 계산한 com 을 앰버 십자로 찍는다(성단이 유영하는 게 보인다)
const c = com();
const cz = FOCAL / (FOCAL + c[2]);
const mx = W * 0.5 + (c[0] - W * 0.5) * cz, my = H * 0.5 + (c[1] - H * 0.5) * cz;
// 별 — 원근 스케일로 크기·밝기 변화. z 가 가까울수록 크고 밝다
for (let i = 0; i < sim.N; i++) {
const p = project(i);
const s = p[2];
const cc = s > 1.06 ? col.hot : col.glow; // 앞쪽 별은 앰버, 뒷쪽은 페리윙클
ctx.fillStyle = rgba(cc, 0.12 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], 4.0 * s, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(cc, 0.66 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], (1.4 + 1.1 * s) * s, 0, Math.PI * 2); ctx.fill();
}
ctx.strokeStyle = rgba(col.hot, 0.85);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx - 5, my); ctx.lineTo(mx + 5, my);
ctx.moveTo(mx, my - 5); ctx.lineTo(mx, my + 5);
ctx.stroke();
}
function stillFrame(): void {
// reduce — 궤도 뭉치가 쌓인 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 200; s++) { step(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.0;
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { step(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
spinRef.current = spin;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, spin]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>구형으로 흩뿌린 별들이 무게중심을 제각각의 각도로 돌며 은하 원반과 달리 안쪽으로 뭉치지 않습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 280개 · 무게중심 자체 계산</span>
<span className={styles.metaDim}>canvas · center of mass · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 球状星団の公転 — 数百の星が共通重心を回る軌道 — 計測 累積面積 22.827% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
3次元の全ペア重力
2次元の盤と同じペアループにdzを1本足すだけで3次元のn体になります。画面の奥行きは投影の問題で、計算そのものはx・y・zを平等に扱います。
function step() {
// 全ペア O(N²) 3次元重力 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (近似なし)
var i, j, dx, dy, dz, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; AZ[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i]; dz = Z[j] - Z[i];
d2 = dx * dx + dy * dy + dz * dz + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f; AZ[i] += dz * f;
AX[j] -= dx * f; AY[j] -= dy * f; AZ[j] -= dz * f;
}
}
for (i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT; VZ[i] += AZ[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; Z[i] += VZ[i] * DT;
}
}
球の等密度播種と接線速度
半径はr³で一様に引き、速度は位置に垂直な接線方向からランダムに選ぶので、軌道面が星ごとに別々になります。円盤のように整列しない星団の核となる性質です。
04ダークマターハロー — 見えない質量を入れると軌道が変わる実験
九つの盤で唯一の近似で、星同士の重力を全ペアで計算せず、毎ステップ星の質量を半径ビン(格子K=28)に累積して球対称の内包質量M(r)を作り、各星は自分の半径の内側の質量ぶんだけ中心へ引かれます。トグルで見えない質量プロファイル ρ ∝ 1/(r²+rc²) を同じ格子に上乗せすると外側の星の円軌道速度が保たれ、6.0秒ループで累積面積37.479%は九つで最も広い塗りです。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>見えない質量ハローを入れると外側の星の速度が落ちません。銀河の端が速い理由です</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="ダークマターハローラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
<button class="gx__halo" id="haloBtn" type="button" aria-pressed="false">
<span id="haloOffLbl">ハローオフ</span><span id="haloOnLbl" hidden>ハローオン</span>
</button>
</div>
<div class="gx__bar">
<span class="gx__meta">星300個・格子質量近似</span>
<span class="gx__meta gx__meta--dim">canvas · grid approximation · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 암흑물질 헤일로(04). 토글 버튼 하나로 헤일로 질량을 넣고 빼는 장면 —
// 격자 질량 근사(별 질량을 반경 빈에 누적 → 구면 M(r)) + leapfrog 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}
// 토글 버튼 — 캔버스 위 오버레이. 눌림 상태는 aria-pressed 로 표현
.gx__halo {
position: absolute; left: 8px; bottom: 8px;
padding: $sp-1 $sp-2;
border: 1px solid $gx-text; border-radius: $r-pill;
background: rgba($gx-ink, .72);
font-weight: 700; font-size: 10px; line-height: 1.4; font-family: inherit; color: $gx-text;
cursor: pointer;
transition: border-color .18s , color .18s ;
}
.gx__halo:hover { border-color: $gx-glow; color: #fff; }
.gx__halo[aria-pressed="true"] { border-color: $gx-hot; color: $gx-hot; }
.gx__halo:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}
.gx__halo {
position: absolute;
left: 8px;
bottom: 8px;
padding: 4px 8px;
border: 1px solid #d8d4e6;
border-radius: 999px;
background: rgba(11, 10, 19, 0.72);
font-weight: 700;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
color: #d8d4e6;
cursor: pointer;
transition: border-color 0.18s, color 0.18s;
}
.gx__halo:hover {
border-color: #b7c8ff;
color: #fff;
}
.gx__halo[aria-pressed=true] {
border-color: #ffdf9e;
color: #ffdf9e;
}
.gx__halo:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}// 암흑물질 헤일로(04) — 토글 한 번으로 은하 회전 곡선이 바뀌는 장면.
// ★ 근사 명시: 이 항목은 별 상호중력을 전 쌍 O(N²) 로 계산하지 않는다.
// 매 스텝 별 질량을 반경 빈(격자)에 누적해 구면 누적 질량 M(r) 을 만들고,
// 각 별은 자기 반경 안쪽 질량만큼 중심 방향으로 끌린다(등방 근사 — 방향 정보를 버린 격자 질량 근사).
// 정확한 전 쌍 계산은 01~03 참고. 근사로 남은 프레임 예산을 회전 곡선 그래프(우상단)에 쓴다.
// 헤일로 ON: 같은 격자에 보이지 않는 질량 프로파일(ρ ∝ 1/(r²+rc²))을 얹어 M(r) 을 불린다 —
// 바깥 별의 원궤도 속도가 유지된다(플랫 회전 곡선). OFF: 바깥이 결합을 못 이겨 팽창한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 300; // 원반 별 수
var HALOBOOST = 2.6; // 헤일로 질량 / 별 총질량 — 변형 "헤일로 질량"이 2.6→5.0으로
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var haloBtn = document.getElementById('haloBtn');
var haloOnLbl = document.getElementById('haloOnLbl');
var haloOffLbl = document.getElementById('haloOffLbl');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [], MASS = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 90;
var DT = 1 / 60;
var HALO = false; // 헤일로 상태 — 자동 루프가 3초마다 전환, 버튼 클릭 시 고정
var autoHalo = true;
// ── 격자 질량 근사 상태 ──
var K = 28; // 반경 빈(격자) 수
var RMAX = 10, RSTEP = 1; // fit() 에서 원반 크기로 결정
var binMass = [], cumMass = [], haloMass = [];
function buildHalo() {
// 헤일로 프로파일 — 고정 분포(별과 충돌하지 않는 차가운 암흑물질 가정).
// ρ ∝ 1/(r²+rc²) 를 격자 빈에 적분해 담아 둔다(한 번만 계산)
var rc = Math.min(W, H) * 0.15;
var mstar = 0;
for (var i = 0; i < N; i++) mstar += MASS[i];
var raw = [];
for (var k = 0; k < K; k++) {
var r0 = rEdge(k), r1 = rEdge(k + 1);
raw.push((Math.atan(r1 / rc) - Math.atan(r0 / rc)) * rc); // ∫ r²/(r²+rc²) dr 근사 형태
}
var sum = 0;
for (k = 0; k < K; k++) sum += raw[k];
for (k = 0; k < K; k++) haloMass[k] = raw[k] / sum * mstar * HALOBOOST;
}
function rEdge(k) { return RMAX * Math.pow(k / K, 0.8); } // 중심 촘촘 격자
function binOf(r) {
var k = Math.ceil(K * Math.pow(Math.min(r, RMAX) / RMAX, 1 / 0.8)) - 1;
return k < 0 ? 0 : (k >= K ? K - 1 : k);
}
var AX = [], AY = [];
function accel() {
// 격자 재구성 — 별 질량을 빈에 누적하고 구면 누적 질량 CM[k] 를 만든다
var i, k;
for (k = 0; k < K; k++) binMass[k] = 0;
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
var rr = Math.hypot(X[i] - cx, Y[i] - cy);
binMass[binOf(rr)] += MASS[i];
}
var acc = 0;
for (k = 0; k < K; k++) {
var hm = HALO ? haloMass[k] : 0; // 토글 — 헤일로 질량을 같은 격자에 얹는다
binMass[k] += hm;
acc += binMass[k];
cumMass[k] = acc;
}
// 각 별의 가속도 — 안쪽 누적 질량 M(r) 만큼 중심 방향(등방 근사: 방향 정보 폐기)
for (i = 0; i < N; i++) {
var dx = cx - X[i], dy = cy - Y[i];
var r = Math.hypot(dx, dy);
var bk = binOf(r);
var Mr = cumMass[bk] - binMass[bk] * 0.5; // 빈 중간값 근사
var d3 = Math.pow(r * r + SOFT2, 1.5);
var f = G * Mr / d3;
AX[i] = dx * f; AY[i] = dy * f;
}
}
var aReady = false;
function stepLeap() {
// leapfrog — 격자 근사 가속도로도 절반 스텝 대칭으로 밀어 에너지를 보존한다
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seed() {
// 원반 — 속도는 헤일로 ON 프로파일 기준 원궤도(플랫에 가깝게)로 심는다.
// OFF 구간에서는 결합이 못 미쳐 바깥 별이 팽창한다(토글 차이가 곧바로 보인다)
X = []; Y = []; VX = []; VY = []; MASS = []; N = 0;
var rnd = mulberry32(5);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.40;
RMAX = R * 1.6;
for (var i = 0; i < NSTARS; i++) {
var r = 0.10 * R + R * 0.90 * Math.sqrt(rnd());
var a = rnd() * Math.PI * 2;
MASS.push(0.5 + rnd() * 0.8);
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.88);
N++;
}
buildHalo();
// 속도는 위치가 다 심어진 뒤 M(r) 격자로 계산(누적 질량 기준 원궤도)
HALO = true; accel(); HALO = false;
for (i = 0; i < N; i++) {
var dx = X[i] - cx, dy = Y[i] - cy;
var v = Math.sqrt(Math.max(AX[i] * -dx + AY[i] * -dy, 1e-6) * Math.hypot(dx, dy));
var tl = Math.hypot(dx, dy) || 1;
VX.push(-dy / tl * v + (rnd() - 0.5) * 1.2);
VY.push( dx / tl * v * 0.88 + (rnd() - 0.5) * 1.2);
}
aReady = false;
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.020)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
chainCtx.strokeStyle = rgba(r < Math.min(W, H) * 0.16 ? C_HOT : C_GLOW, 0.24);
chainCtx.lineWidth = 1.2;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 아래 그리기 전부 이 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(6);
ctx.fillStyle = rgba(C_GLOW, 0.4);
for (var k = 0; k < 34; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 코어는 파스텔 앰버, 바깥은 파스텔 페리윙클. 글로우 두 겹
var cx = W * 0.5, cy = H * 0.5, R = Math.min(W, H);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - cx, Y[i] - cy);
var col = r < R * 0.15 ? C_HOT : C_GLOW;
ctx.fillStyle = rgba(col, 0.12);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.6 + 0.35 * (1 - r / (R * 0.42)));
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.3, 0, Math.PI * 2); ctx.fill();
}
// ハローオン — 보이지 않는 질량의 반경 스케일을 옅은 링으로만 암시한다
if (HALO) {
ctx.strokeStyle = rgba(C_TAIL, 0.30);
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.arc(cx, cy, R * 0.52, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
}
// 회전 곡선 인셋 — 빈별 평균 접선속도 실측. OFF: 하강 / ON: 플랫
var gx0 = W - 104, gy0 = 12, gw = 88, gh = 62;
ctx.fillStyle = rgba(C_INK, 0.78);
ctx.fillRect(gx0, gy0, gw, gh);
ctx.strokeStyle = rgba(C_GLOW, 0.35);
ctx.lineWidth = 1;
ctx.strokeRect(gx0 + 0.5, gy0 + 0.5, gw, gh);
var vsum = [], vcnt = [];
for (k = 0; k < K; k++) { vsum[k] = 0; vcnt[k] = 0; }
var vmax = 1;
for (i = 0; i < N; i++) {
var dx = X[i] - cx, dy = Y[i] - cy;
var vt = Math.abs(-dy * VX[i] + dx * VY[i]) / Math.max(Math.hypot(dx, dy), 1);
var bk = binOf(Math.hypot(dx, dy));
vsum[bk] += vt; vcnt[bk]++;
if (vt > vmax) vmax = vt;
}
ctx.strokeStyle = rgba(HALO ? C_TAIL : C_GLOW, 0.95);
ctx.lineWidth = 1.6;
ctx.beginPath();
var started = false;
for (k = 2; k < K; k++) {
if (!vcnt[k]) continue;
var vx = gx0 + 6 + (k / K) * (gw - 12);
var vy = gy0 + gh - 8 - (vsum[k] / vcnt[k] / vmax) * (gh - 18);
if (!started) { ctx.moveTo(vx, vy); started = true; }
ctx.lineTo(vx, vy);
}
ctx.stroke();
ctx.fillStyle = rgba(C_HOT, 0.8);
ctx.font = '9px sans-serif';
ctx.fillText(HALO ? 'flat' : 'kepler', gx0 + 6, gy0 + 11);
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
function setHalo(on) {
HALO = on;
haloBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
haloOnLbl.hidden = !on;
haloOffLbl.hidden = on;
}
haloBtn.addEventListener('click', function () { autoHalo = false; setHalo(!HALO); });
field.addEventListener('click', function (e) {
if (e.target === haloBtn || haloBtn.contains(e.target)) return;
autoHalo = false; setHalo(!HALO);
});
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 6.0; // 6초 루프 — 3초 꺼짐 / 3초 켜짐 자동 전환
if (autoHalo) setHalo(loop < 3.0);
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 헤일로 ON 장면까지 적분한 뒤 한 장 고정
setHalo(true);
for (var s = 0; s < 240; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyDarkMatterHalo — vanilla 판(run/322/demo/04-galaxy-nbody-dark-matter-halo/index.html) 의 React 포트.
// 암흑물질 헤일로 — 토글 한 번으로 은하 회전 곡선이 바뀌는 장면.
// ★ 근사 명시: 이 항목은 별 상호중력을 전 쌍 O(N²) 로 계산하지 않는다.
// 매 스텝 별 질량을 반경 빈(격자)에 누적해 구면 누적 질량 M(r) 을 만들고,
// 각 별은 자기 반경 안쪽 질량만큼 중심 방향으로 끌린다(등방 근사 — 방향 정보를 버린 격자 질량 근사).
// 헤일로 ON: 같은 격자에 보이지 않는 질량 프로파일(ρ ∝ 1/(r²+rc²))을 얹어 M(r) 을 불린다 —
// 바깥 별의 원궤도 속도가 유지된다(플랫 회전 곡선). OFF: 바깥이 결합을 못 이겨 팽창한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyDarkMatterHalo.module.scss";
export type GalaxyNbodyDarkMatterHaloProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
haloboost?: number; // 헤일로 질량 / 별 총질량 — 변형 "헤일로 질량"이 2.6→5.0으로
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type SimM = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[]; MASS: number[] };
const FIELD_LABEL = "암흑물질 헤일로 실험판";
const LABEL_OFF = "헤일로 꺼짐";
const LABEL_ON = "헤일로 켜짐";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 90;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyDarkMatterHalo({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 300,
haloboost = 2.6
}: GalaxyNbodyDarkMatterHaloProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const boostRef = useRef(haloboost) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [haloOn, setHaloOn] = useState(false); // 버튼 라벨 상태 — 시뮬 상태(HALORef)와 동기
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[], MASS: [] as number[] }) as Box<SimM>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
const HALORef = useRef(false) as Box<boolean>; // 헤일로 상태 — 자동 루프가 3초마다 전환, 버튼 클릭 시 고정
const autoHaloRef = useRef(true) as Box<boolean>;
// ── 격자 질량 근사 상태 ──
const K = 28; // 반경 빈(격자) 수
const rmaxRef = useRef(10) as Box<number>;
const binMassRef = useRef<number[]>([]) as Box<number[]>;
const cumMassRef = useRef<number[]>([]) as Box<number[]>;
const haloMassRef = useRef<number[]>([]) as Box<number[]>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function rEdge(k: number): number { return rmaxRef.current * Math.pow(k / K, 0.8); } // 중심 촘촘 격자
function binOf(r: number): number {
const k = Math.ceil(K * Math.pow(Math.min(r, rmaxRef.current) / rmaxRef.current, 1 / 0.8)) - 1;
return k < 0 ? 0 : (k >= K ? K - 1 : k);
}
function buildHalo(): void {
// 헤일로 프로파일 — 고정 분포(별과 충돌하지 않는 차가운 암흑물질 가정).
// ρ ∝ 1/(r²+rc²) 를 격자 빈에 적분해 담아 둔다(한 번만 계산)
const sim = simRef.current;
const rc = Math.min(wRef.current, hRef.current) * 0.15;
let mstar = 0;
for (let i = 0; i < sim.N; i++) mstar += sim.MASS[i];
const raw: number[] = [];
for (let k = 0; k < K; k++) {
const r0 = rEdge(k), r1 = rEdge(k + 1);
raw.push((Math.atan(r1 / rc) - Math.atan(r0 / rc)) * rc); // ∫ r²/(r²+rc²) dr 근사 형태
}
let sum = 0;
for (let k = 0; k < K; k++) sum += raw[k];
for (let k = 0; k < K; k++) haloMassRef.current[k] = raw[k] / sum * mstar * boostRef.current;
}
function accel(): void {
// 격자 재구성 — 별 질량을 빈에 누적하고 구면 누적 질량 CM[k] 를 만든다
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const binMass = binMassRef.current, cumMass = cumMassRef.current, haloMass = haloMassRef.current;
const W = wRef.current, H = hRef.current;
let i: number, k: number;
for (k = 0; k < K; k++) binMass[k] = 0;
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < sim.N; i++) {
const rr = Math.hypot(sim.X[i] - cx, sim.Y[i] - cy);
binMass[binOf(rr)] += sim.MASS[i];
}
let acc = 0;
for (k = 0; k < K; k++) {
const hm = HALORef.current ? haloMass[k] : 0; // 토글 — 헤일로 질량을 같은 격자에 얹는다
binMass[k] += hm;
acc += binMass[k];
cumMass[k] = acc;
}
// 각 별의 가속도 — 안쪽 누적 질량 M(r) 만큼 중심 방향(등방 근사: 방향 정보 폐기)
for (i = 0; i < sim.N; i++) {
const dx = cx - sim.X[i], dy = cy - sim.Y[i];
const r = Math.hypot(dx, dy);
const bk = binOf(r);
const Mr = cumMass[bk] - binMass[bk] * 0.5; // 빈 중간값 근사
const d3 = Math.pow(r * r + SOFT2, 1.5);
const f = G * Mr / d3;
AX[i] = dx * f; AY[i] = dy * f;
}
}
function stepLeap(): void {
// leapfrog — 격자 근사 가속도로도 절반 스텝 대칭으로 밀어 에너지를 보존한다
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function seed(): void {
// 원반 — 속도는 헤일로 ON 프로파일 기준 원궤도(플랫에 가깝게)로 심는다.
// OFF 구간에서는 결합이 못 미쳐 바깥 별이 팽창한다(토글 차이가 곧바로 보인다)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.MASS = []; sim.N = 0;
const rnd = mulberry32(5);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.40;
rmaxRef.current = R * 1.6;
for (let i = 0; i < nstarsRef.current; i++) {
const r = 0.10 * R + R * 0.90 * Math.sqrt(rnd());
const a = rnd() * Math.PI * 2;
sim.MASS.push(0.5 + rnd() * 0.8);
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.88);
sim.N++;
}
buildHalo();
// 속도는 위치가 다 심어진 뒤 M(r) 격자로 계산(누적 질량 기준 원궤도)
HALORef.current = true; accel(); HALORef.current = false;
for (let i = 0; i < sim.N; i++) {
const dx = sim.X[i] - cx, dy = sim.Y[i] - cy;
const v = Math.sqrt(Math.max(AX[i] * -dx + AY[i] * -dy, 1e-6) * Math.hypot(dx, dy));
const tl = Math.hypot(dx, dy) || 1;
sim.VX.push(-dy / tl * v + (rnd() - 0.5) * 1.2);
sim.VY.push(dx / tl * v * 0.88 + (rnd() - 0.5) * 1.2);
}
aReadyRef.current = false;
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.020)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
chainCtx.strokeStyle = rgba(r < Math.min(W, H) * 0.16 ? col.hot : col.glow, 0.24);
chainCtx.lineWidth = 1.2;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(6);
ctx.fillStyle = rgba(col.glow, 0.4);
for (let k = 0; k < 34; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 코어는 파스텔 앰버, 바깥은 파스텔 페리윙클. 글로우 두 겹
const cx = W * 0.5, cy = H * 0.5, R = Math.min(W, H);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - cx, sim.Y[i] - cy);
const c = r < R * 0.15 ? col.hot : col.glow;
ctx.fillStyle = rgba(c, 0.12);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.6 + 0.35 * (1 - r / (R * 0.42)));
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.3, 0, Math.PI * 2); ctx.fill();
}
// 헤일로 켜짐 — 보이지 않는 질량의 반경 스케일을 옅은 링으로만 암시한다
if (HALORef.current) {
ctx.strokeStyle = rgba(col.tail, 0.30);
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.arc(cx, cy, R * 0.52, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
}
// 회전 곡선 인셋 — 빈별 평균 접선속도 실측. OFF: 하강 / ON: 플랫
const gx0 = W - 104, gy0 = 12, gw = 88, gh = 62;
ctx.fillStyle = rgba(col.ink, 0.78);
ctx.fillRect(gx0, gy0, gw, gh);
ctx.strokeStyle = rgba(col.glow, 0.35);
ctx.lineWidth = 1;
ctx.strokeRect(gx0 + 0.5, gy0 + 0.5, gw, gh);
const vsum: number[] = [], vcnt: number[] = [];
for (let k = 0; k < K; k++) { vsum[k] = 0; vcnt[k] = 0; }
let vmax = 1;
for (let i = 0; i < sim.N; i++) {
const dx = sim.X[i] - cx, dy = sim.Y[i] - cy;
const vt = Math.abs(-dy * sim.VX[i] + dx * sim.VY[i]) / Math.max(Math.hypot(dx, dy), 1);
const bk = binOf(Math.hypot(dx, dy));
vsum[bk] += vt; vcnt[bk]++;
if (vt > vmax) vmax = vt;
}
ctx.strokeStyle = rgba(HALORef.current ? col.tail : col.glow, 0.95);
ctx.lineWidth = 1.6;
ctx.beginPath();
let started = false;
for (let k = 2; k < K; k++) {
if (!vcnt[k]) continue;
const vx = gx0 + 6 + (k / K) * (gw - 12);
const vy = gy0 + gh - 8 - (vsum[k] / vcnt[k] / vmax) * (gh - 18);
if (!started) { ctx.moveTo(vx, vy); started = true; }
ctx.lineTo(vx, vy);
}
ctx.stroke();
ctx.fillStyle = rgba(col.hot, 0.8);
ctx.font = "9px sans-serif";
ctx.fillText(HALORef.current ? "flat" : "kepler", gx0 + 6, gy0 + 11);
}
function setHalo(on: boolean): void {
HALORef.current = on;
setHaloOn(on);
}
function stillFrame(): void {
// reduce — 헤일로 ON 장면까지 적분한 뒤 한 장을 고정한다
setHalo(true);
for (let s = 0; s < 240; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 6.0; // 6초 루프 — 3초 꺼짐 / 3초 켜짐 자동 전환
if (autoHaloRef.current) setHalo(loop < 3.0);
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const field = fieldRef.current;
if (!root || !field) return;
nstarsRef.current = nstars;
boostRef.current = haloboost;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
autoHaloRef.current = true;
// 토글 — 버튼 클릭 시 자동 전환을 끄고 고정. 필드 클릭도 같은 동작(버튼 누름 제외)
function onFieldClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
if (target.closest("button")) return;
autoHaloRef.current = false;
setHalo(!HALORef.current);
}
field.addEventListener("click", onFieldClick);
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
field.removeEventListener("click", onFieldClick);
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, haloboost]);
function onHaloBtn(): void {
autoHaloRef.current = false;
setHalo(!HALORef.current);
}
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>보이지 않는 질량 헤일로를 켜면 바깥 별의 회전 속도가 떨어지지 않고 유지됩니다 — 은하 가장자리가 빠른 이유</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<button
className={styles.haloBtn}
type="button"
aria-pressed={haloOn}
onClick={onHaloBtn}
>
<span hidden={haloOn}>{LABEL_OFF}</span>
<span hidden={!haloOn}>{LABEL_ON}</span>
</button>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 300개 · 격자 질량 근사</span>
<span className={styles.metaDim}>canvas · grid approximation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}04 · ダークマターハロー — 見えない質量を入れると軌道が変わる実験 — 計測 累積面積 37.479% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ハローの質量プロファイルを格子に積分する
ハローの質量は星と衝突しない固定分布として先に計算し、HALOBOOST=2.6倍を星の総質量に掛けて格子に配ります。rcは画面の短辺の0.15倍です。
function buildHalo() {
// ハロープロファイル — 固定分布(星と衝突しない冷たいダークマターの想定)。
// ρ ∝ 1/(r²+rc²) を格子ビンに積分して収めておく(1回だけ計算)
var rc = Math.min(W, H) * 0.15;
var mstar = 0;
for (var i = 0; i < N; i++) mstar += MASS[i];
var raw = [];
for (var k = 0; k < K; k++) {
var r0 = rEdge(k), r1 = rEdge(k + 1);
raw.push((Math.atan(r1 / rc) - Math.atan(r0 / rc)) * rc); // ∫ r²/(r²+rc²) dr の近似形
}
var sum = 0;
for (k = 0; k < K; k++) sum += raw[k];
for (k = 0; k < K; k++) haloMass[k] = raw[k] / sum * mstar * HALOBOOST;
}
方向情報を捨てる代償
この近似は星を中心方向へだけ引く等方近似なので、潮汐尾のような横に引かれる構造は再現できません。回転曲線という1つの目的のために方向の情報を捨てる盤で、ほかの8つが全ペアの直接計算である理由がここにあります。
05合体残骸 — 渦巻きが砕けて丸くなる結末
始まりは最初の接近ではなく2回目の直前で、シードで2つの円盤を近くに置き、PREPASS=420ステップを先に積分して、最初のすれ違いと尾の形成が済んだ状態から始めます。画面の時間が流れると軌道の向きが混ざって渦巻き構造が散らばり、特徴のない滑らかな楕円体が残る盤で、全ペアO(N²)とleapfrogは01と同じ近似なしの計算です。フレーム最大4.899%は2度目以降の乱れが穏やかな性格の表れです。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>衝突後も時間を流すと腕は散らばり軌道が混ざり、滑らかな楕円体が残ります</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="合体残骸ラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">2回目の接近から・星320個</span>
<span class="gx__meta gx__meta--dim">canvas · virial mix · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 합체 잔해 타원 은하(05). 첫 스침 이후 상태에서 시작해 타원체로 안정되는 장면 —
// 나선 팔 형성(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 + 별 상호 중력 O(N²) 레인포드 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 합체 잔해 타원 은하(05) — 첫 스침을 지난 시점부터 시간을 흐르게 두는 장면.
// 시작은 두 은하가 이미 한 번 스쳐 꼬리가 늘어선 상태(두 번째 스침 직전):
// 시드에서 첫 스침을 미리 적분해 만든다(두 원반을 가깝게 놓고 몇백 스텝 = 첫 스침).
// 이후 화면의 시간이 흐르면 궤도 방향이 뒤섞이고(virial 화) 나선팔 구조가 흍어져
// 특징 없는 타원체가 남는다 — 관측되는 타원 은하가 합체 잔해라는 설명의 재현.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 적분(근사 아님).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 320; // 두 은하 합계 별 수
var PREPASS = 420; // 시작 전 미리 흘려 둘 스텝(첫 스침+꼬리 형성) — 변형 "시작 시점"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv100pct();
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function cv100pct() { cv.style.width = '100%'; cv.style.height = '100%'; }
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 140.0, SOFT2 = 16;
var DT = 1 / 60;
var AX = [], AY = [];
function accel() {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
// leapfrog — 절반 스텝 대칭 킥으로 에너지를 오래 보존(장면이 몇 초 흐러도 부풀지 않는다)
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
// 원반 — r² 균등 면밀도, 원궤도 속도(01 과 동일 결정론 시더)
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
N++;
}
}
function seed() {
// 두 원반을 첫 스침 직전 거리(가깝게)에 놓고 PREPASS 스텝 미리 적분 —
// 화면의 t=0 은 이미 한 번 스치고 꼬리가 늘어선 두 번째 스침 직전 상태다
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
var rnd = mulberry32(9);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.44;
var sc = R;
placeDisk(W * 0.36, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.64, H * 0.66, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
for (var s = 0; s < PREPASS; s++) stepLeap();
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.012)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
chainCtx.strokeStyle = rgba(i < (N >> 1) ? C_GLOW : C_TAIL, 0.30);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 모든 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(10);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 타원체 잔해 중심은 앰버 코어. ellipse 글로우를 겹쳐 은하 전체 윤곽을 암시한다
var cx = 0, cy = 0;
for (i = 0; i < N; i++) { cx += X[i]; cy += Y[i]; }
cx /= N; cy /= N;
var sx = 0, sy = 0;
for (i = 0; i < N; i++) { sx += (X[i] - cx) * (X[i] - cx); sy += (Y[i] - cy) * (Y[i] - cy); }
var rx = Math.sqrt(sx / N) * 1.9, ry = Math.sqrt(sy / N) * 1.9;
ctx.fillStyle = rgba(C_HOT, 0.055);
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 0.05);
ctx.beginPath(); ctx.ellipse(cx, cy, rx * 0.72, ry * 0.72, 0, 0, Math.PI * 2); ctx.fill();
// 별 점 — 은하 A 페리윙클 · 은하 B 핑크(합쳐지며 섞인다)
for (var i = 0; i < N; i++) {
var col = i < (N >> 1) ? C_GLOW : C_TAIL;
var d = Math.hypot(X[i] - cx, Y[i] - cy);
var near = Math.max(0, 1 - d / (Math.min(W, H) * 0.70));
ctx.fillStyle = rgba(col, 0.10 + near * 0.10);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.55 + near * 0.45);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.7 + near * 1.0, 0, Math.PI * 2); ctx.fill();
}
// 중심부 — 앰버 핵 점(무게중심)
ctx.fillStyle = rgba(C_HOT, 0.95);
ctx.beginPath(); ctx.arc(cx, cy, 2.4, 0, Math.PI * 2); ctx.fill();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 4.0; // 4초 루프 — 두 번째 스침 → 궤도 뒤섞임 → 타원체
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤도가 뒤섞여 타원체가 잡히는 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 300; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyMergerRemnant — vanilla 판(run/322/demo/05-galaxy-nbody-merger-remnant/index.html) 의 React 포트.
// 합체 잔해 타원 은하 — 첫 스침을 지난 시점부터 시간을 흐르게 두는 장면.
// 시작은 두 은하가 이미 한 번 스쳐 꼬리가 늘어선 상태(두 번째 스침 직전):
// 시드에서 첫 스침을 미리 적분해 만든다(두 원반을 가깝게 놓고 몇백 스텝 = 첫 스침).
// 이후 화면의 시간이 흐르면 궤도 방향이 뒤섞이고(virial 화) 나선팔 구조가 흍어져
// 특징 없는 타원체가 남는다 — 관측되는 타원 은하가 합체 잔해라는 설명의 재현.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 적분(근사 아님).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyMergerRemnant.module.scss";
export type GalaxyNbodyMergerRemnantProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 두 은하 합계 별 수
prepass?: number; // 시작 전 미리 흘려 둘 스텝(첫 스침+꼬리 형성) — 변형 "시작 시점"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const FIELD_LABEL = "합체 잔해 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 140.0, SOFT2 = 16;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyMergerRemnant({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 320,
prepass = 420
}: GalaxyNbodyMergerRemnantProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const prepassRef = useRef(prepass) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
// leapfrog — 절반 스텝 대칭 킥으로 에너지를 오래 보존(장면이 몇 초 흐러도 부풀지 않는다)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, rnd: () => number, sc: number): void {
// 원반 — r² 균등 면밀도, 원궤도 속도(01 과 동일 결정론 시더)
const sim = simRef.current;
const M = R * R * 0.90;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd());
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.N++;
}
}
function seed(): void {
// 두 원반을 첫 스침 직전 거리(가깝게)에 놓고 PREPASS 스텝 미리 적분 —
// 화면의 t=0 은 이미 한 번 스치고 꼬리가 늘어선 두 번째 스침 직전 상태다
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
const rnd = mulberry32(9);
const half = nstarsRef.current >> 1;
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.44;
const sc = R;
placeDisk(W * 0.36, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.64, H * 0.66, R, nstarsRef.current - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
for (let s = 0; s < prepassRef.current; s++) stepLeap();
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.012)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
chainCtx.strokeStyle = rgba(i < (sim.N >> 1) ? col.glow : col.tail, 0.30);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(10);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 타원체 잔해 중심은 앰버 코어. ellipse 글로우를 겹쳐 은하 전체 윤곽을 암시한다
let cx = 0, cy = 0;
for (let i = 0; i < sim.N; i++) { cx += sim.X[i]; cy += sim.Y[i]; }
cx /= sim.N; cy /= sim.N;
let sx = 0, sy = 0;
for (let i = 0; i < sim.N; i++) { sx += (sim.X[i] - cx) * (sim.X[i] - cx); sy += (sim.Y[i] - cy) * (sim.Y[i] - cy); }
const rx = Math.sqrt(sx / sim.N) * 1.9, ry = Math.sqrt(sy / sim.N) * 1.9;
ctx.fillStyle = rgba(col.hot, 0.055);
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 0.05);
ctx.beginPath(); ctx.ellipse(cx, cy, rx * 0.72, ry * 0.72, 0, 0, Math.PI * 2); ctx.fill();
// 별 점 — 은하 A 페리윙클 · 은하 B 핑크(합쳐지며 섞인다)
for (let i = 0; i < sim.N; i++) {
const c = i < (sim.N >> 1) ? col.glow : col.tail;
const d = Math.hypot(sim.X[i] - cx, sim.Y[i] - cy);
const near = Math.max(0, 1 - d / (Math.min(W, H) * 0.70));
ctx.fillStyle = rgba(c, 0.10 + near * 0.10);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.55 + near * 0.45);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.7 + near * 1.0, 0, Math.PI * 2); ctx.fill();
}
// 중심부 — 앰버 핵 점(무게중심)
ctx.fillStyle = rgba(col.hot, 0.95);
ctx.beginPath(); ctx.arc(cx, cy, 2.4, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 궤도가 뒤섞여 타원체가 잡히는 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 300; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 4.0; // 4초 루프 — 두 번째 스침 → 궤도 뒤섞임 → 타원체
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
prepassRef.current = prepass;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, prepass]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>충돌이 지나도 시간을 계속 흐르게 두면 나선팔이 흩어지고 궤도 방향이 뒤섞여 특징 없는 타원체가 남습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>두 번째 스침부터 시작 · 별 320개</span>
<span className={styles.metaDim}>canvas · virial mix · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 · 合体残骸 — 渦巻きが砕けて丸くなる結末 — 計測 累積面積 32.382% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
2回目の接近から始めるPREPASS
最初の接近を飛ばすには、シードの直後に420ステップを黙って積分するだけです。時刻t=0をどこに置くかの選択だけで、合体の結末が主題の盤として成立します。
function seed() {
// 2つの円盤を最初のすれ違い直前の距離(近め)に置き、PREPASS ステップを先に積分 —
// 画面の t=0 は、すでに一度すれ違って尾が並んだ2回目の接近直前の状態
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
var rnd = mulberry32(9);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.44;
var sc = R;
placeDisk(W * 0.36, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.64, H * 0.66, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
for (var s = 0; s < PREPASS; s++) stepLeap();
}
軌道の向きが混ざる結末
すれ違いを2度経験した星の軌道は向きが入り混じり、渦巻きの特徴が消えた楕円体に落ち着きます。合体残骸が楕円銀河であるという説明の再現です。
06潮汐尾のクローズアップ — ある1星の軌跡で見る放出
計算は01と同じ星340個の全ペアO(N²)とleapfrogで、変わるのは描き方だけです。他の星は薄い点と短い残像にして、円盤の外側に植えた追跡星(TRACK)1個の座標だけをTRAILN=180のリングバッファに太いアンバーの軌跡として積みます。2つの中心の間を行き来しながら潮汐の蹴りで放り出される個別軌道が1本の線で見える盤です。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>数千のうち1星だけ軌跡を残すと、尾ができる瞬間その星が2つの中心を行き来して放たれます</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="潮汐尾クローズアップラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">星340個・追跡は1軌道</span>
<span class="gx__meta gx__meta--dim">canvas · one-star trail · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 조석 꼬리 클로즈업(06). 한 별만 굵은 궤적으로 추적하는 장면 —
// 나선 팔 형성(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 + 별 상호 중력 O(N²) 레인포드 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 조석 꼬리 클로즈업(06) — 수천 별 중 딱 한 별의 궤적만 굵게 남기는 장면.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog(근사 아님).
// 다른 점은 렌더 뿐이다:
// - 다른 별은 흐린 점 + 짧은 잔상(집단의 흐름만 보인다)
// - 추적 별(TRACK 인덱스, 원반 바깥쪽에 심는다)은 굵은 앰버 궤적을
// 별도 트레일 버퍼(좌표 180개 링)에 쌓아 lineTo 로 이어 그린다 —
// 두 은하 중심 사이를 왕복(Lissajous)하다 조석 킥에 던져지는 과정이 한 줄로 드러난다.
// "꼬리는 별 하나하나의 궤도가 아니라 궤도의 집합"이라는 08 의 전 단계 시연.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 340; // 두 은하 합계 별 수
var TRAILN = 180; // 추적 별 궤적 길이(프레임) — 변형 "궤적 길이"가 180→360으로
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
var TRACK = -1; // 추적 별 인덱스 — seed() 에서 원반 가장자리로 정한다
var trailX = [], trailY = []; // 추적 별 궤적 링 버퍼
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 140.0, SOFT2 = 16;
var DT = 1 / 60;
var AX = [], AY = [];
function accel() {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
var M = R * R * 0.90;
var start = N;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
N++;
}
// 추적 별 — 첫 원반 바깥쪽 마지막 별(조석 꼬리가 먼저 잡히는 위치)
if (TRACK < 0 && start === 0) TRACK = N - 1;
}
function seed() {
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
TRACK = -1; trailX = []; trailY = [];
var rnd = mulberry32(13);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.46;
var sc = R;
placeDisk(W * 0.26, H * 0.30, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.74, H * 0.70, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
// 집단 잔상은 빨리 지운다(궤적이 아니라 흐름만 남긴다) — 추적 궤적은 trail 버퍼가 기억
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.028)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
if (i === TRACK) continue; // 추적 별은 여기 찍지 않는다(굵은 궤적이 대신한다)
chainCtx.strokeStyle = rgba(i < (N >> 1) ? C_GLOW : C_TAIL, 0.26);
chainCtx.lineWidth = 1.6;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
// 추적 궤적 링 버퍼 — 오래된 좌표는 밀려난다
trailX.push(X[TRACK]); trailY.push(Y[TRACK]);
if (trailX.length > TRAILN) { trailX.shift(); trailY.shift(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(14);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 추적 궤적 — 굵은 앰버 라인. 오래된 세그먼트일수록 옅게(페이드)
var tn = trailX.length;
if (tn > 1) {
for (var s = 1; s < tn; s++) {
var age = s / tn; // 0 오래됨 → 1 최신
ctx.strokeStyle = rgba(C_HOT, 0.10 + age * 0.75);
ctx.lineWidth = 0.8 + age * 1.9;
ctx.beginPath();
ctx.moveTo(trailX[s - 1], trailY[s - 1]);
ctx.lineTo(trailX[s], trailY[s]);
ctx.stroke();
}
}
// 별 — 집단은 흐린 점, 추적 별은 큰 앰버 글로우 + 코어
for (var i = 0; i < N; i++) {
if (i === TRACK) continue;
var col = i < (N >> 1) ? C_GLOW : C_TAIL;
ctx.fillStyle = rgba(col, 0.11);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.58);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(C_HOT, 0.16);
ctx.beginPath(); ctx.arc(X[TRACK], Y[TRACK], 7.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 0.55);
ctx.beginPath(); ctx.arc(X[TRACK], Y[TRACK], 3.6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1.0);
ctx.beginPath(); ctx.arc(X[TRACK], Y[TRACK], 1.6, 0, Math.PI * 2); ctx.fill();
// 은하 B 코어 표식
ctx.fillStyle = rgba(C_GLOW, 0.85);
ctx.beginPath(); ctx.arc(X[N >> 1], Y[N >> 1], 2.0, 0, Math.PI * 2); ctx.fill();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 스침 → 왕복 → 킥 순간 반복
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 킥 직후까지 적분한 뒤 한 장 고정
for (var s = 0; s < 170; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyTidalTail — vanilla 판(run/322/demo/06-galaxy-nbody-tidal-tail/index.html) 의 React 포트.
// 조석 꼬리 클로즈업 — 수천 별 중 딱 한 별의 궤적만 굵게 남기는 장면.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog(근사 아님).
// 다른 점은 렌더 뿐이다:
// - 다른 별은 흐린 점 + 짧은 잔상(집단의 흐름만 보인다)
// - 추적 별(TRACK 인덱스, 원반 바깥쪽에 심는다)은 굵은 앰버 궤적을
// 별도 트레일 버퍼(좌표 180개 링)에 쌓아 lineTo 로 이어 그린다 —
// 두 은하 중심 사이를 왕복(Lissajous)하다 조석 킥에 던져지는 과정이 한 줄로 드러난다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyTidalTail.module.scss";
export type GalaxyNbodyTidalTailProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 두 은하 합계 별 수
trailn?: number; // 추적 별 궤적 길이(프레임) — 변형 "궤적 길이"가 180→360으로
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const FIELD_LABEL = "조석 꼬리 클로즈업 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 140.0, SOFT2 = 16;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyTidalTail({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 340,
trailn = 180
}: GalaxyNbodyTidalTailProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const trailnRef = useRef(trailn) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
const trackRef = useRef(-1) as Box<number>; // 추적 별 인덱스 — seed() 에서 원반 가장자리로 정한다
const trailXRef = useRef<number[]>([]) as Box<number[]>;
const trailYRef = useRef<number[]>([]) as Box<number[]>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, rnd: () => number, sc: number): void {
const sim = simRef.current;
const M = R * R * 0.90;
const start = sim.N;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd());
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.N++;
}
// 추적 별 — 첫 원반 바깥쪽 마지막 별(조석 꼬리가 먼저 잡히는 위치)
if (trackRef.current < 0 && start === 0) trackRef.current = sim.N - 1;
}
function seed(): void {
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
trackRef.current = -1;
trailXRef.current = []; trailYRef.current = [];
const rnd = mulberry32(13);
const half = nstarsRef.current >> 1;
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.46;
const sc = R;
placeDisk(W * 0.26, H * 0.30, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.74, H * 0.70, R, nstarsRef.current - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
}
function decay(): void {
// 집단 잔상은 빨리 지운다(궤적이 아니라 흐름만 남긴다) — 추적 궤적은 trail 버퍼가 기억
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.028)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
if (i === trackRef.current) continue; // 추적 별은 여기 찍지 않는다(굵은 궤적이 대신한다)
chainCtx.strokeStyle = rgba(i < (sim.N >> 1) ? col.glow : col.tail, 0.26);
chainCtx.lineWidth = 1.6;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
// 추적 궤적 링 버퍼 — 오래된 좌표는 밀려난다
trailXRef.current.push(sim.X[trackRef.current]);
trailYRef.current.push(sim.Y[trackRef.current]);
if (trailXRef.current.length > trailnRef.current) { trailXRef.current.shift(); trailYRef.current.shift(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(14);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 추적 궤적 — 굵은 앰버 라인. 오래된 세그먼트일수록 옅게(페이드)
const trailX = trailXRef.current, trailY = trailYRef.current;
const tn = trailX.length;
if (tn > 1) {
for (let s = 1; s < tn; s++) {
const age = s / tn; // 0 오래됨 → 1 최신
ctx.strokeStyle = rgba(col.hot, 0.10 + age * 0.75);
ctx.lineWidth = 0.8 + age * 1.9;
ctx.beginPath();
ctx.moveTo(trailX[s - 1], trailY[s - 1]);
ctx.lineTo(trailX[s], trailY[s]);
ctx.stroke();
}
}
// 별 — 집단은 흐린 점, 추적 별은 큰 앰버 글로우 + 코어
const trk = trackRef.current;
for (let i = 0; i < sim.N; i++) {
if (i === trk) continue;
const c = i < (sim.N >> 1) ? col.glow : col.tail;
ctx.fillStyle = rgba(c, 0.11);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.58);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(col.hot, 0.16);
ctx.beginPath(); ctx.arc(sim.X[trk], sim.Y[trk], 7.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 0.55);
ctx.beginPath(); ctx.arc(sim.X[trk], sim.Y[trk], 3.6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 1.0);
ctx.beginPath(); ctx.arc(sim.X[trk], sim.Y[trk], 1.6, 0, Math.PI * 2); ctx.fill();
// 은하 B 코어 표식
ctx.fillStyle = rgba(col.glow, 0.85);
ctx.beginPath(); ctx.arc(sim.X[sim.N >> 1], sim.Y[sim.N >> 1], 2.0, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 킥 직후까지 적분한 뒤 한 장 고정
for (let s = 0; s < 170; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 스침 → 왕복 → 킥 순간 반복
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
trailnRef.current = trailn;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, trailn]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>수천 별 중 한 별만 궤적을 남기면, 꼬리가 만들어지는 순간 그 별이 두 중심 사이를 왕복하다 던져집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 340개 · 추적 1개 궤적</span>
<span className={styles.metaDim}>canvas · one-star trail · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · 潮汐尾のクローズアップ — ある1星の軌跡で見る放出 — 計測 累積面積 20.762% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
古いほど薄くなる軌跡のフェード
リングバッファの座標を古い順に線で繋ぎ、透明度を最新のセグメントほど濃くします。1本の線の濃淡が時間の流れをそのまま運びます。
// 追跡軌跡 — 太いアンバーの線。古いセグメントほど薄く(フェード)
var tn = trailX.length;
if (tn > 1) {
for (var s = 1; s < tn; s++) {
var age = s / tn; // 0 古い → 1 最新
ctx.strokeStyle = rgba(C_HOT, 0.10 + age * 0.75);
ctx.lineWidth = 0.8 + age * 1.9;
ctx.beginPath();
ctx.moveTo(trailX[s - 1], trailY[s - 1]);
ctx.lineTo(trailX[s], trailY[s]);
ctx.stroke();
}
}
統計ではなく1つの物語
尾を数字の変化ではなく個別の軌道として見ると、2つの中心の間の往復と放出が同じ線に刻まれます。累積面積20.762%は追跡対象が1個であることの直接の結果です。
07彗星を撃つ — ドラッグの角度と強さで星を投げ込む
円盤銀河(描かない中心質量 MHALO=5400 と星180個)の舞台に、ドラッグで引っ張った反対方向へスリングショット発射する盤です。彗星は配列の末尾に追加され、特別扱いなしで同じ全ペアの重力場に入り、角度と速度次第で楕円軌道に捕まったり双曲線で脱出したりします。LAUNCHV=0.55の倍率が発射速度を決め、描画では6発が決まった角度で自動発射されます。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>銀河からドラッグして星を投げ込んでください。角度で軌道に乗ることも脱出することもあります</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="彗星発射ラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
<span class="gx__aim" id="aimLbl" hidden>ドラッグで発射</span>
</div>
<div class="gx__bar">
<span class="gx__meta">星180個・中心質量が引力</span>
<span class="gx__meta gx__meta--dim">canvas · drag to launch · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 혜성 쏘기(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 인력 + 발사체 O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 혜성 쏘기(07) — 포인터로 발사체를 던져 넣는 조작 장면.
// 무대: 원반 은하(중심 질량 + 별 180개). 드래그(pointerdown → pointermove → pointerup)로
// 새 혜성을 만들어 던진다 — 놓은 지점에서 당긴 반대 방향으로 slingshot 발사.
// 혜성은 같은 O(N²) 중력장에 그대로 들어간다: 각도·속도에 따라
// · 궤도에 잡혀 타원 궤도(중심 질량 지배) — 첫 스침 후 포획
// · 스침이 살짝 어긋나 그대로 탈출(쌍곡선) — 중력 도우거림
// is-demo(렌더)에서는 입력 없이도 3발이 정해진 각도로 자동 발사된다(결정론 렌더).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 180; // 원반 별 수
var LAUNCHV = 0.55; // 발사 속도 배율(캔버스 대각선 비례) — 변형 "발사 속도"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var aimLbl = document.getElementById('aimLbl');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
// 혜성은 별 배열에 그대로 추가된다(같은 O(N²) 계산 — 특별 대우 없음)
var NC0 = 0; // 원반 별 수 고정(혜성 이전)
var comets = []; // {x, y, vx, vy, born, trail: []}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 40;
var DT = 1 / 60;
var MHALO = 5400; // 중심 질량 — 원반을 지탱하는 핵(그리지 않는다)
var AX = [], AY = [];
function accel() {
// 중심 질량 + 전 쌍 O(N²) 별 상호 중력(혜성 포함 — 배열 뒤에 추가돼 있다)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function seed() {
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false; comets = [];
var rnd = mulberry32(17);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.46;
for (var i = 0; i < NSTARS; i++) {
var far = rnd() < 0.38; // 바깥 별 — 원반 밖까지 오가는 넓은 궤도
var r = far
? R * (0.46 + 1.75 * rnd())
: 0.12 * R + R * 0.80 * Math.sqrt(rnd());
var a = rnd() * Math.PI * 2;
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.0);
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.90);
VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.4);
VY.push( Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.4);
N++;
}
NC0 = N;
}
function addComet(px, py, vx, vy, born) {
// 혜성 — 별 배열 뒤에 추가(같은 중력 계산). 오래된 혜성은 되감을 때 같이 지운다
X.push(px); Y.push(py); VX.push(vx); VY.push(vy); N++;
aReady = false; // AX/AY 버퍼가 이전 크기(혜성 이전)다 — 재계산 전에 쓰면 undefined*NaN 전파
if (prev) { prev[0].push(px); prev[1].push(py); } // 잔상 이전점 배열도 같은 길이로
comets.push({ idx: N - 1, born: born, trail: [] });
if (comets.length > 4) {
var old = comets.shift();
removeStar(old.idx);
}
}
function removeStar(idx) {
// 배열 압축 — 인덱스가 뒤로 밀리는 혜성 참조를 같이 고친다
X.splice(idx, 1); Y.splice(idx, 1); VX.splice(idx, 1); VY.splice(idx, 1); N--;
aReady = false;
if (prev) { prev[0].splice(idx, 1); prev[1].splice(idx, 1); }
for (var c = 0; c < comets.length; c++) {
if (comets[c].idx > idx) comets[c].idx--;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.013)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
var isComet = i >= NC0;
chainCtx.strokeStyle = rgba(isComet ? C_HOT : C_GLOW, isComet ? 0.55 : 0.24);
chainCtx.lineWidth = isComet ? 2.6 : 1.5;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw(aim) {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(18);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 원반 별 — 페리윙클 점
for (var i = 0; i < NC0; i++) {
ctx.fillStyle = rgba(C_GLOW, 0.11);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_GLOW, 0.6);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 중심 질량 자리 — 앰버 점
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.4, 0, Math.PI * 2); ctx.fill();
// 혜성 — 앰버 큰 글로우 + 코어
for (i = NC0; i < N; i++) {
ctx.fillStyle = rgba(C_HOT, 0.18);
ctx.beginPath(); ctx.arc(X[i], Y[i], 9, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 0.65);
ctx.beginPath(); ctx.arc(X[i], Y[i], 4.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath(); ctx.arc(X[i], Y[i], 2.0, 0, Math.PI * 2); ctx.fill();
}
// 조준선 — 드래그 중에만. 당긴 방향 반대로 발사된다(slingshot)
if (aim) {
ctx.strokeStyle = rgba(C_TAIL, 0.9);
ctx.lineWidth = 1.4;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x1, aim.y1);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = rgba(C_HOT, 0.95);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x0 + (aim.x0 - aim.x1) * 2.2, aim.y0 + (aim.y0 - aim.y1) * 2.2);
ctx.stroke();
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(aim.x0, aim.y0, 3, 0, Math.PI * 2); ctx.fill();
}
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
// ── 포인터 발사 — pointerdown → pointermove(조준) → pointerup(발사) ──
var aim = null;
function ptr(e) {
var r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
field.addEventListener('pointerdown', function (e) {
var p = ptr(e);
aim = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
aimLbl.hidden = false;
aimLbl.textContent = 'ドラッグで発射';
e.preventDefault();
});
field.addEventListener('pointermove', function (e) {
if (!aim) return;
var p = ptr(e);
aim.x1 = p.x; aim.y1 = p.y;
});
field.addEventListener('pointerup', function (e) {
if (!aim) return;
var p = ptr(e);
var dx = aim.x0 - p.x, dy = aim.y0 - p.y; // 당긴 반대 = 발사 방향
var len = Math.hypot(dx, dy);
if (len > 6) {
var sp = Math.min(260, len * 2.2) * LAUNCHV;
addComet(aim.x0, aim.y0, dx / len * sp, dy / len * sp, simSteps);
}
aim = null; aimLbl.hidden = true;
});
field.addEventListener('pointerleave', function () { aim = null; aimLbl.hidden = true; });
var simSteps = 0;
// 자동 발사 스케줄 — is-demo 렌더에서도 장면이 차게 찬다(결정론: 시드·각도 고정)
function autoSchedule() {
return [
{ t: 10, x: 0.04, y: 0.14, ang: 0.48, sp: 1.00 },
{ t: 34, x: 0.96, y: 0.20, ang: 2.35, sp: 0.90 },
{ t: 58, x: 0.48, y: 0.03, ang: 1.20, sp: 0.66 },
{ t: 80, x: 0.08, y: 0.86, ang: 0.80, sp: 1.10 },
{ t: 100, x: 0.92, y: 0.90, ang: 3.70, sp: 0.95 },
{ t: 116, x: 0.30, y: 0.06, ang: 1.60, sp: 0.75 }
];
}
var AUTO = autoSchedule();
function fireAuto() {
var diag = Math.hypot(W, H);
while (AUTO.length && AUTO[0].t <= simSteps) {
var a = AUTO.shift();
var sp = diag * 0.42 * a.sp * LAUNCHV;
addComet(W * a.x, H * a.y, Math.cos(a.ang) * sp, Math.sin(a.ang) * sp, simSteps);
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.6; // 3.6초 루프 — 4연발 → 궤도/탈출이 갈리는 순간
var target = Math.floor(loop / DT);
while (simSteps < target) { fireAuto(); stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; AUTO = autoSchedule(); chainCtx.clearRect(0, 0, W, H); }
decay();
draw(aim);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 혜성 2발이 궤도에 오른 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 100; s++) { fireAuto(); stepLeap(); simSteps++; stamp(); }
draw(null);
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
AUTO = autoSchedule(); // 스케줄 되감기
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyLaunchComet — vanilla 판(run/322/demo/07-galaxy-nbody-launch-comet/index.html) 의 React 포트.
// 혜성 쏘기 — 포인터로 발사체를 던져 넣는 조작 장면.
// 무대: 원반 은하(중심 질량 + 별 180개). 드래그(pointerdown → pointermove → pointerup)로
// 새 혜성을 만들어 던진다 — 놓은 지점에서 당긴 반대 방향으로 slingshot 발사.
// 혜성은 같은 O(N²) 중력장에 그대로 들어간다: 각도·속도에 따라
// · 궤도에 잡혀 타원 궤도(중심 질량 지배) — 첫 스침 후 포획
// · 스침이 살짝 어긋나 그대로 탈출(쌍곡선) — 중력 도우거림
// 포인터 입력이 없어도 6발이 정해진 각도로 자동 발사된다(결정론 렌더).
// ※ 데모 소스의 AX/AY 버퍼 동기 수정(혜성 추가·삭제 시 aReady 리셋)이 이미 반영된 판을 그대로 포트한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as RPointerEvent } from "react";
import styles from "./GalaxyNbodyLaunchComet.module.scss";
export type GalaxyNbodyLaunchCometProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
launchv?: number; // 발사 속도 배율(캔버스 대각선 비례) — 변형 "발사 속도"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
type Aim = { x0: number; y0: number; x1: number; y1: number };
type AutoShot = { t: number; x: number; y: number; ang: number; sp: number };
type Comet = { idx: number; born: number };
const FIELD_LABEL = "혜성 쏘기 실험판";
const AIM_LABEL = "드래그로 발사";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 40;
const DT = 1 / 60;
const MHALO = 5400; // 중심 질량 — 원반을 지탱하는 핵(그리지 않는다)
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자동 발사 스케줄 — 포인터 입력이 없어도 장면이 차게 찬다(결정론: 시드·각도 고정)
function autoSchedule(): AutoShot[] {
return [
{ t: 10, x: 0.04, y: 0.14, ang: 0.48, sp: 1.00 },
{ t: 34, x: 0.96, y: 0.20, ang: 2.35, sp: 0.90 },
{ t: 58, x: 0.48, y: 0.03, ang: 1.20, sp: 0.66 },
{ t: 80, x: 0.08, y: 0.86, ang: 0.80, sp: 1.10 },
{ t: 100, x: 0.92, y: 0.90, ang: 3.70, sp: 0.95 },
{ t: 116, x: 0.30, y: 0.06, ang: 1.60, sp: 0.75 }
];
}
export default function GalaxyNbodyLaunchComet({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 180,
launchv = 0.55
}: GalaxyNbodyLaunchCometProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const launchvRef = useRef(launchv) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [aiming, setAiming] = useState(false); // 조준 라벨 표시
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
const nc0Ref = useRef(0) as Box<number>; // 원반 별 수 고정(혜성 이전)
const cometsRef = useRef<Comet[]>([]) as Box<Comet[]>;
const aimRef = useRef<Aim | null>(null) as Box<Aim | null>;
const autoRef = useRef<AutoShot[]>([]) as Box<AutoShot[]>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 중심 질량 + 전 쌍 O(N²) 별 상호 중력(혜성 포함 — 배열 뒤에 추가돼 있다)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const W = wRef.current, H = hRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - sim.X[i]; dy = cy - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function seed(): void {
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
cometsRef.current = [];
const rnd = mulberry32(17);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.46;
for (let i = 0; i < nstarsRef.current; i++) {
const far = rnd() < 0.38; // 바깥 별 — 원반 밖까지 오가는 넓은 궤도
const r = far
? R * (0.46 + 1.75 * rnd())
: 0.12 * R + R * 0.80 * Math.sqrt(rnd());
const a = rnd() * Math.PI * 2;
const v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.0);
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.90);
sim.VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.4);
sim.VY.push(Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.4);
sim.N++;
}
nc0Ref.current = sim.N;
}
function removeStar(idx: number): void {
// 배열 압축 — 인덱스가 뒤로 밀리는 혜성 참조를 같이 고친다
const sim = simRef.current;
const prev = prevRef.current;
sim.X.splice(idx, 1); sim.Y.splice(idx, 1); sim.VX.splice(idx, 1); sim.VY.splice(idx, 1); sim.N--;
aReadyRef.current = false;
if (prev) { prev[0].splice(idx, 1); prev[1].splice(idx, 1); }
for (let c = 0; c < cometsRef.current.length; c++) {
if (cometsRef.current[c].idx > idx) cometsRef.current[c].idx--;
}
}
function addComet(px: number, py: number, vx: number, vy: number, born: number): void {
// 혜성 — 별 배열 뒤에 추가(같은 중력 계산). 오래된 혜성은 되감을 때 같이 지운다
const sim = simRef.current;
const prev = prevRef.current;
sim.X.push(px); sim.Y.push(py); sim.VX.push(vx); sim.VY.push(vy); sim.N++;
aReadyRef.current = false; // AX/AY 버퍼가 이전 크기(혜성 이전)다 — 재계산 전에 쓰면 undefined*NaN 전파
if (prev) { prev[0].push(px); prev[1].push(py); } // 잔상 이전점 배열도 같은 길이로
cometsRef.current.push({ idx: sim.N - 1, born: born });
if (cometsRef.current.length > 4) {
const old = cometsRef.current.shift();
if (old) removeStar(old.idx);
}
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.013)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const isComet = i >= nc0Ref.current;
chainCtx.strokeStyle = rgba(isComet ? col.hot : col.glow, isComet ? 0.55 : 0.24);
chainCtx.lineWidth = isComet ? 2.6 : 1.5;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
const aim = aimRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(18);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 원반 별 — 페리윙클 점
const nc0 = nc0Ref.current;
for (let i = 0; i < nc0; i++) {
ctx.fillStyle = rgba(col.glow, 0.11);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.glow, 0.6);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 중심 질량 자리 — 앰버 점
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.4, 0, Math.PI * 2); ctx.fill();
// 혜성 — 앰버 큰 글로우 + 코어
for (let i = nc0; i < sim.N; i++) {
ctx.fillStyle = rgba(col.hot, 0.18);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 9, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 0.65);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 4.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 2.0, 0, Math.PI * 2); ctx.fill();
}
// 조준선 — 드래그 중에만. 당긴 방향 반대로 발사된다(slingshot)
if (aim) {
ctx.strokeStyle = rgba(col.tail, 0.9);
ctx.lineWidth = 1.4;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x1, aim.y1);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = rgba(col.hot, 0.95);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x0 + (aim.x0 - aim.x1) * 2.2, aim.y0 + (aim.y0 - aim.y1) * 2.2);
ctx.stroke();
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(aim.x0, aim.y0, 3, 0, Math.PI * 2); ctx.fill();
}
}
function fireAuto(): void {
// 자동 발사 — 정해진 시각에 스케줄의 혜성을 만들어 던진다
const W = wRef.current, H = hRef.current;
const diag = Math.hypot(W, H);
const AUTO = autoRef.current;
while (AUTO.length && AUTO[0].t <= simStepsRef.current) {
const a = AUTO.shift();
if (!a) break;
const sp = diag * 0.42 * a.sp * launchvRef.current;
addComet(W * a.x, H * a.y, Math.cos(a.ang) * sp, Math.sin(a.ang) * sp, simStepsRef.current);
}
}
// ── 포인터 발사 — pointerdown → pointermove(조준) → pointerup(발사) ──
function ptr(e: RPointerEvent<HTMLDivElement>): { x: number; y: number } {
const cv = cvRef.current;
if (!cv) return { x: 0, y: 0 };
const r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function onPointerDown(e: RPointerEvent<HTMLDivElement>): void {
const p = ptr(e);
aimRef.current = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
setAiming(true);
e.preventDefault();
}
function onPointerMove(e: RPointerEvent<HTMLDivElement>): void {
const aim = aimRef.current;
if (!aim) return;
const p = ptr(e);
aim.x1 = p.x; aim.y1 = p.y;
}
function onPointerUp(e: RPointerEvent<HTMLDivElement>): void {
const aim = aimRef.current;
if (!aim) return;
const p = ptr(e);
const dx = aim.x0 - p.x, dy = aim.y0 - p.y; // 당긴 반대 = 발사 방향
const len = Math.hypot(dx, dy);
if (len > 6) {
const sp = Math.min(260, len * 2.2) * launchvRef.current;
addComet(aim.x0, aim.y0, dx / len * sp, dy / len * sp, simStepsRef.current);
}
aimRef.current = null;
setAiming(false);
}
function onPointerLeave(): void {
aimRef.current = null;
setAiming(false);
}
function stillFrame(): void {
// reduce — 혜성 2발이 궤도에 오른 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 100; s++) { fireAuto(); stepLeap(); simStepsRef.current++; stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.6; // 3.6초 루프 — 4연발 → 궤도/탈출이 갈리는 순간
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { fireAuto(); stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
autoRef.current = autoSchedule();
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
launchvRef.current = launchv;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
autoRef.current = autoSchedule();
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
autoRef.current = autoSchedule(); // 스케줄 되감기
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, launchv]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>은하 위를 드래그해 새 별을 던져 넣어 보세요. 각도에 따라 궤도에 잡히기도, 그대로 탈출하기도 합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<span className={styles.aim} hidden={!aiming}>{AIM_LABEL}</span>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 180개 · 끌어당기는 중심 질량</span>
<span className={styles.metaDim}>canvas · drag to launch · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · 彗星を撃つ — ドラッグの角度と強さで星を投げ込む — 計測 累積面積 26.731% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ドラッグの3つのポインターイベント
押した点と離した点の距離と向きがそのまま発射の強さと方向になります。長さは2.2倍して上限260で抑え、6px未満のずれは無視します。
field.addEventListener('pointerdown', function (e) {
var p = ptr(e);
aim = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
aimLbl.hidden = false;
aimLbl.textContent = 'ドラッグで発射';
e.preventDefault();
});
field.addEventListener('pointermove', function (e) {
if (!aim) return;
var p = ptr(e);
aim.x1 = p.x; aim.y1 = p.y;
});
field.addEventListener('pointerup', function (e) {
if (!aim) return;
var p = ptr(e);
var dx = aim.x0 - p.x, dy = aim.y0 - p.y; // 引いた反対 = 発射方向
var len = Math.hypot(dx, dy);
if (len > 6) {
var sp = Math.min(260, len * 2.2) * LAUNCHV;
addComet(aim.x0, aim.y0, dx / len * sp, dy / len * sp, simSteps);
}
aim = null; aimLbl.hidden = true;
});
field.addEventListener('pointerleave', function () { aim = null; aimLbl.hidden = true; });
重力が本物である手での確かめ方
同じ計算の中に手で投げた星が入るので、楕円に捕まるか脱出するかは初速だけで決まります。累積面積26.731%・強度55.3は九つの盤の中でも安定した帯です。
08軌跡ヒストリー — 残像で積もる星の過去
01〜07の残像が毎フレーム少しずつ消えていたのに対し、この盤は減衰をなくして過去フレームをglobalAlphaで重ねて敷くだけです。軌道が積み続けられ、渦巻き腕が1枚の腕ではなく数百の軌道の累積であることが時間とともに見えてきます。計算は02と同じ全ペアO(N²)と中心質量 MHALO=6200 のleapfrogで、強度平均62.6は九つで最も明るい数値です。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>過去フレームをアルファで敷くと軌道が柔らかい線に積もります。腕は1枚ではなく軌道の累積です</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="軌跡ヒストリーラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">星300個・残像レイヤー</span>
<span class="gx__meta gx__meta--dim">canvas · globalAlpha history · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 궤적 히스토리(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(별 상호 중력 O(N²) 레인포드 적분 → 잔상 누적)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 궤적 히스토리(08) — 잔상을 "지우는" 게 아니라 "쌓는" 장면.
// 01~07 의 잔상 체인은 destination-out 으로 매 프레임 조금씩 지웠다(흐름만 남긴다).
// 여기선 정반대: 배경 위에 지난 프레임을 globalAlpha 로 깔아 두기만 한다 —
// decay 가 없으므로 궤도가 계속 쌓여 나선팔이 "한 장의 팔"이 아니라
// "수백 개 궤도의 누적"임이 시간이 지날수록 드러난다(장면의 주제 그 자체).
// 계산은 02 와 같은 전 쌍 O(N²) + 중심질량 + leapfrog(근사 아님).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 300; // 원반 별 수
var LAYERS = 3; // 잔상 누적 단수(스텝당 깔리는 알파 겹) — 변형 "누적 깊이"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 36;
var DT = 1 / 60;
var MHALO = 6200; // 중심 질량(핵+헤일로) — 그리지 않는다. 차등회전의 원천
var AX = [], AY = [];
function accel() {
// 중심 질량 + 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function seed() {
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
var rnd = mulberry32(21);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.46;
for (var i = 0; i < NSTARS; i++) {
var far = rnd() < 0.44; // 상당수 별은 이심 궤도 — 궤적이 원반 밖까지 뻗는다
var r = far
? R * (0.22 + 2.30 * rnd()) // 먼 별: 최대 ~2.5R 바깥까지 오가는 궤도
: 0.10 * R + R * 0.80 * Math.sqrt(rnd());
var a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 초기 밀도 요동 — 쌓이면 나선팔
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.06); // 원반 살짝 과속 → 궤적이 살짝 벌어진다
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.94);
VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
VY.push( Math.cos(a) * v * 0.94 + (rnd() - 0.5) * 1.5);
N++;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// decay 가 없다 — 잔상 체인은 지워지지 않고 계속 쌓인다(이 데모의 주제).
// 대신 루프 되감을 때만 통째로 지운다.
var prev = null;
function stamp() {
// 누적 — 스텝마다 globalAlpha 를 낮게 해 여러 겹 깔면 궤도가 선으로 굳는다
if (prev) {
for (var L = 0; L < LAYERS; L++) { // LAYERS 겹 — 쌓임 속도 변형
chainCtx.globalAlpha = 0.22 / LAYERS * (L + 1);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < Math.min(W, H) * 0.18 ? C_HOT : C_GLOW;
chainCtx.strokeStyle = rgba(col, 0.9);
chainCtx.lineWidth = r < Math.min(W, H) * 0.18 ? 2.6 : 2.1;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
chainCtx.globalAlpha = 1;
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(23);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 누적 레이어 — 지워지지 않은 궤도 전체가 한 번에 얹힌다
ctx.drawImage(chainCv, 0, 0, W, H);
// 현재 별 위치 — 밝은 코어(쌓인 궤도 위 지나가는 "지금")
var R = Math.min(W, H);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < R * 0.16 ? C_HOT : C_GLOW;
ctx.fillStyle = rgba(col, 0.18);
ctx.beginPath(); ctx.arc(X[i], Y[i], 4.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.9);
ctx.beginPath(); ctx.arc(X[i], Y[i], 2.4, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.2, 0, Math.PI * 2); ctx.fill();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 5.0; // 5초 루프 — 길게 흘려 궤도가 충분히 쌓이게
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤도가 쌓여 팔이 드러난 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 260; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyTrailHistory — vanilla 판(run/322/demo/08-galaxy-nbody-trail-history/index.html) 의 React 포트.
// 궤적 히스토리 — 잔상을 "지우는" 게 아니라 "쌓는" 장면.
// 01~07 의 잔상 체인은 destination-out 으로 매 프레임 조금씩 지웠다(흐름만 남긴다).
// 여기선 정반대: 배경 위에 지난 프레임을 globalAlpha 로 깔아 두기만 한다 —
// decay 가 없으므로 궤도가 계속 쌓여 나선팔이 "한 장의 팔"이 아니라
// "수백 개 궤도의 누적"임이 시간이 지날수록 드러난다(장면의 주제 그 자체).
// 계산은 02 와 같은 전 쌍 O(N²) + 중심질량 + leapfrog(근사 아님).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyTrailHistory.module.scss";
export type GalaxyNbodyTrailHistoryProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
layers?: number; // 잔상 누적 단수(스텝당 깔리는 알파 겹) — 변형 "누적 깊이"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const FIELD_LABEL = "궤적 히스토리 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 36;
const DT = 1 / 60;
const MHALO = 6200; // 중심 질량(핵+헤일로) — 그리지 않는다. 차등회전의 원천
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyTrailHistory({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 300,
layers = 3
}: GalaxyNbodyTrailHistoryProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const layersRef = useRef(layers) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 중심 질량 + 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const W = wRef.current, H = hRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - sim.X[i]; dy = cy - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function seed(): void {
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
const rnd = mulberry32(21);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.46;
for (let i = 0; i < nstarsRef.current; i++) {
const far = rnd() < 0.44; // 상당수 별은 이심 궤도 — 궤적이 원반 밖까지 뻗는다
const r = far
? R * (0.22 + 2.30 * rnd()) // 먼 별: 최대 ~2.5R 바깥까지 오가는 궤도
: 0.10 * R + R * 0.80 * Math.sqrt(rnd());
const a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 초기 밀도 요동 — 쌓이면 나선팔
const v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.06); // 원반 살짝 과속 → 궤적이 살짝 벌어진다
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.94);
sim.VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
sim.VY.push(Math.cos(a) * v * 0.94 + (rnd() - 0.5) * 1.5);
sim.N++;
}
}
// decay 가 없다 — 잔상 체인은 지워지지 않고 계속 쌓인다(이 데모의 주제).
// 대신 루프 되감을 때만 통째로 지운다.
function stamp(): void {
// 누적 — 스텝마다 globalAlpha 를 낮게 해 여러 겹 깔면 궤도가 선으로 굳는다
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let L = 0; L < layersRef.current; L++) { // LAYERS 겹 — 쌓임 속도 변형
chainCtx.globalAlpha = 0.22 / layersRef.current * (L + 1);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < Math.min(W, H) * 0.18 ? col.hot : col.glow;
chainCtx.strokeStyle = rgba(c, 0.9);
chainCtx.lineWidth = r < Math.min(W, H) * 0.18 ? 2.6 : 2.1;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
chainCtx.globalAlpha = 1;
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(23);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 누적 레이어 — 지워지지 않은 궤도 전체가 한 번에 얹힌다
ctx.drawImage(chainCv, 0, 0, W, H);
// 현재 별 위치 — 밝은 코어(쌓인 궤도 위 지나가는 "지금")
const R = Math.min(W, H);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < R * 0.16 ? col.hot : col.glow;
ctx.fillStyle = rgba(c, 0.18);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 4.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.9);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 2.4, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.2, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 궤도가 쌓여 팔이 드러난 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 260; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 5.0; // 5초 루프 — 길게 흘려 궤도가 충분히 쌓이게
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
layersRef.current = layers;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, layers]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>지난 프레임을 알파로 깔아 두면 궤도가 부드러운 선으로 쌓이고, 나선팔이 한 장이 아니라 궤도의 누적임이 드러납니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 300개 · 잔상 누적 레이어</span>
<span className={styles.metaDim}>canvas · globalAlpha history · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · 軌跡ヒストリー — 残像で積もる星の過去 — 計測 累積面積 32.485% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
消さずに重ねるLAYERS枚の描画
ステップごとにglobalAlphaを低くした描画をLAYERS=3枚重ねると、軌道が線として固まります。内側の18%の半径は hot 色、外側は glow 色で塗り分けます。
function stamp() {
// 累積 — ステップごとに globalAlpha を低くして何枚も重ねると軌道が線として固まる
if (prev) {
for (var L = 0; L < LAYERS; L++) { // LAYERS 枚 — 積み上がる速さの変形
chainCtx.globalAlpha = 0.22 / LAYERS * (L + 1);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < Math.min(W, H) * 0.18 ? C_HOT : C_GLOW;
chainCtx.strokeStyle = rgba(col, 0.9);
chainCtx.lineWidth = r < Math.min(W, H) * 0.18 ? 2.6 : 2.1;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
chainCtx.globalAlpha = 1;
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
腕は軌道の密度である証拠
積もった軌道の文様と02で成長する腕を並べると、腕が星を縛る構造ではなく軌道が重なる場所であることが数値でも裏付けられます。5.0秒のループはこの積み上がりを見せるための長さです。
09ディープフィールドシーン — 何億年も込めた銀河進化の舞台
createRadialGradientで別キャンバスに焼いた星雲スプライト3枚(ターコイズ・ローズ・アンバー)を毎フレーム重ね、その上に3つの銀河(星110+110+110、銀河ごとに違うパステル3色)を対角線で交差する軌道に植えます。すれ違い→潮汐尾→合体(楕円化)の順の進化が1ループの中で現れる締めの盤で、星雲は毎フレーム作り直さず焼いた1枚を載せるだけです。

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>星雲を敷いた深宇宙で、複数の銀河が次々すれ違い合体する進化シーケンスです</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="深宇宙銀河進化ラボ" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">銀河3つ・星330個</span>
<span class="gx__meta gx__meta--dim">canvas · nebula sprites · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 심우주 씬(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(성운 스프라이트 + 은하 3개 O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__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: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__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: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__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: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 심우주 씬(09) — 성운을 깐 무대에 은하 3개를 놓는 장면.
// 무대: createRadialGradient 로 구운 성운 스프라이트 3장(청록·장미·앰버)을
// 미리 별도 캔버스에 구워 두고 매 프레임 drawImage 로 얹는다 — 매 프레임 그라디언트를
// 다시 만들지 않아 330개 별 계산과 함께 돌아도 프레임이 나간다.
// 은하 3개(별 110+110+110)는 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 으로 적분한다(근사 아님).
// 궤도는 대각선으로 엇갈리게 심는다 — 스침 → 조석 꼬리 → 합체(타원화) 순서의 진화 시퀀스가
// 한 루프 안에 차례로 나타난다. 별 색은 은하마다 다른 파스텔 3색.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 330; // 세 은하 합계 별 수
var NEBULA = 3; // 성운 스프라이트 수 — 변형 "성운 밀도"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
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('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var nebCvs = []; // 구워 둔 성운 스프라이트
var N = 0, X = [], Y = [], VX = [], VY = [], GID = []; // GID — 별이 속한 은하(색·코어 계산용)
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
bakeNebulas();
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 성운 스프라이트 — createRadialGradient 로 한 번 구워 둔다(매 프레임 재계산 없음)
function bakeNebulas() {
nebCvs = [];
var rnd = mulberry32(77);
var cols = [C_GLOW, C_TAIL, C_HOT];
for (var k = 0; k < NEBULA; k++) {
var s = document.createElement('canvas');
var sz = Math.max(160, Math.round(Math.min(W, H) * (0.55 + rnd() * 0.5)));
s.width = sz; s.height = sz;
var sc = s.getContext('2d');
var g = sc.createRadialGradient(sz * 0.5, sz * 0.5, 0, sz * 0.5, sz * 0.5, sz * 0.5);
var c = cols[k % 3];
g.addColorStop(0.00, rgba(c, 0.16));
g.addColorStop(0.35, rgba(c, 0.07));
g.addColorStop(0.70, rgba(c, 0.02));
g.addColorStop(1.00, rgba(c, 0));
sc.fillStyle = g;
sc.fillRect(0, 0, sz, sz);
nebCvs.push({ cv: s, x: 0.12 + rnd() * 0.76, y: 0.10 + rnd() * 0.80, sz: sz });
}
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 120.0, SOFT2 = 20;
var DT = 1 / 60;
var AX = [], AY = [];
function accel() {
// 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, gid, rnd) {
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * R * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * R * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
GID.push(gid);
N++;
}
}
function seed() {
// 세 은하 — 대각선으로 엇갈리는 궤도. 시간이 흐르면 두 쌍이 스치고 세째가 합류한다.
// 산개 별(STRAY)을 섞어 궤적이 캔버스 구석까지 뻗게 한다 — 심우주의 백업 별밭 역할
X = []; Y = []; VX = []; VY = []; GID = []; N = 0; aReady = false;
var rnd = mulberry32(29);
var per = Math.floor(NSTARS / 3);
var R = Math.min(W, H) * 0.28;
placeDisk(W * 0.22, H * 0.26, R, per - 28, 0, 0.62, -0.40, 1.00, 0, rnd);
placeDisk(W * 0.78, H * 0.52, R, per - 28, Math.PI, -0.62, 0.40, 0.85, 1, rnd);
placeDisk(W * 0.38, H * 0.82, R, NSTARS - 2 * per - 28, Math.PI * 0.5, 0.34, -0.62, 0.92, 2, rnd);
// 산개 별 84개 — 세 은하 사이 공간을 채워 스치는 순간 전체가 살아 난다
for (var s = 0; s < 84; s++) {
var sx = W * (0.04 + rnd() * 0.92);
var sy = H * (0.06 + rnd() * 0.88);
var sa = rnd() * Math.PI * 2;
var sv = 38 + rnd() * 34;
X.push(sx); Y.push(sy);
VX.push(Math.cos(sa) * sv); VY.push(Math.sin(sa) * sv);
GID.push(s % 3);
N++;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function gcol(gid) { return [C_GLOW, C_TAIL, C_HOT][gid % 3]; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.013)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
chainCtx.strokeStyle = rgba(gcol(GID[i]), 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 심우주 배경 — 잉크
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
// 먼 별밭(정적·시드 고정)
var rnd = mulberry32(31);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 55; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.10 + rnd() * 0.24;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 성운 스프라이트 — 구워 둔 것을 얹는다. 아주 느린 드리프트로 심우주 배경 자체를 살린다
for (var n = 0; n < nebCvs.length; n++) {
var nb = nebCvs[n];
var ph = n * 2.1;
var dx = Math.sin(simSteps * 0.0031 + ph) * W * 0.035;
var dy = Math.cos(simSteps * 0.0026 + ph * 1.7) * H * 0.045;
var sc = 1 + Math.sin(simSteps * 0.0022 + ph) * 0.10;
var sz2 = nb.sz * sc;
ctx.drawImage(nb.cv, W * nb.x + dx - sz2 * 0.5, H * nb.y + dy - sz2 * 0.5, sz2, sz2);
}
// 궤도 잔상
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하별 파스텔 3색
for (var i = 0; i < N; i++) {
var col = gcol(GID[i]);
ctx.fillStyle = rgba(col, 0.10);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.6);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 각 은하의 코어 — 같은 색 밝은 점(무게중심)
for (var g = 0; g < 3; g++) {
var sx = 0, sy = 0, c = 0;
for (var i2 = 0; i2 < N; i2++) {
if (GID[i2] !== g) continue;
sx += X[i2]; sy += Y[i2]; c++;
}
if (!c) continue;
ctx.fillStyle = rgba(gcol(g), 0.9);
ctx.beginPath(); ctx.arc(sx / c, sy / c, 2.2, 0, Math.PI * 2); ctx.fill();
}
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 4.5; // 4.5초 루프 — 스침 → 꼬리 → 합류 순서가 다 보이게
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 첫 스침이 일어난 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 200; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyDeepFieldScene — vanilla 판(run/322/demo/09-galaxy-nbody-deep-field-scene/index.html) 의 React 포트.
// 심우주 씬 — 성운을 깐 무대에 은하 3개를 놓는 장면.
// 무대: createRadialGradient 로 구운 성운 스프라이트 3장(청록·장미·앰버)을
// 미리 별도 캔버스에 구워 두고 매 프레임 drawImage 로 얹는다 — 매 프레임 그라디언트를
// 다시 만들지 않아 330개 별 계산과 함께 돌아도 프레임이 나간다.
// 은하 3개(별 110+110+110)는 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 으로 적분한다(근사 아님).
// 궤도는 대각선으로 엇갈리게 심는다 — 스침 → 조석 꼬리 → 합체(타원화) 순서의 진화 시퀀스가
// 한 루프 안에 차례로 나타난다. 별 색은 은하마다 다른 파스텔 3색.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyDeepFieldScene.module.scss";
export type GalaxyNbodyDeepFieldSceneProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 세 은하 합계 별 수
nebula?: number; // 성운 스프라이트 수 — 변형 "성운 밀도"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type SimG = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[]; GID: number[] };
type NebSprite = { cv: HTMLCanvasElement; x: number; y: number; sz: number };
const FIELD_LABEL = "심우주 은하 진화 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 120.0, SOFT2 = 20;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyDeepFieldScene({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 330,
nebula = 3
}: GalaxyNbodyDeepFieldSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nebRef = useRef<NebSprite[]>([]) as Box<NebSprite[]>; // 구워 둔 성운 스프라이트
const nstarsRef = useRef(nstars) as Box<number>;
const nebulaRef = useRef(nebula) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
function gcol(gid: number): RGB {
const col = colRef.current;
if (!col) return [0, 0, 0];
return [col.glow, col.tail, col.hot][gid % 3];
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[], GID: [] as number[] }) as Box<SimG>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
bakeNebulas();
}
// 성운 스프라이트 — createRadialGradient 로 한 번 구워 둔다(매 프레임 재계산 없음)
function bakeNebulas(): void {
const col = colRef.current;
if (!col) return;
nebRef.current = [];
const rnd = mulberry32(77);
const W = wRef.current, H = hRef.current;
const cols: RGB[] = [col.glow, col.tail, col.hot];
for (let k = 0; k < nebulaRef.current; k++) {
const s = document.createElement("canvas");
const sz = Math.max(160, Math.round(Math.min(W, H) * (0.55 + rnd() * 0.5)));
s.width = sz; s.height = sz;
const sc = s.getContext("2d");
if (!sc) continue;
const g = sc.createRadialGradient(sz * 0.5, sz * 0.5, 0, sz * 0.5, sz * 0.5, sz * 0.5);
const c = cols[k % 3];
g.addColorStop(0.00, rgba(c, 0.16));
g.addColorStop(0.35, rgba(c, 0.07));
g.addColorStop(0.70, rgba(c, 0.02));
g.addColorStop(1.00, rgba(c, 0));
sc.fillStyle = g;
sc.fillRect(0, 0, sz, sz);
nebRef.current.push({ cv: s, x: 0.12 + rnd() * 0.76, y: 0.10 + rnd() * 0.80, sz: sz });
}
}
function accel(): void {
// 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, gid: number, rnd: () => number): void {
const sim = simRef.current;
const M = R * R * 0.90;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd());
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * R * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * R * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.GID.push(gid);
sim.N++;
}
}
function seed(): void {
// 세 은하 — 대각선으로 엇갈리는 궤도. 시간이 흐르면 두 쌍이 스치고 세째가 합류한다.
// 산개 별(STRAY)을 섞어 궤적이 캔버스 구석까지 뻗게 한다 — 심우주의 백업 별밭 역할
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.GID = []; sim.N = 0;
aReadyRef.current = false;
const rnd = mulberry32(29);
const W = wRef.current, H = hRef.current;
const per = Math.floor(nstarsRef.current / 3);
const R = Math.min(W, H) * 0.28;
placeDisk(W * 0.22, H * 0.26, R, per - 28, 0, 0.62, -0.40, 1.00, 0, rnd);
placeDisk(W * 0.78, H * 0.52, R, per - 28, Math.PI, -0.62, 0.40, 0.85, 1, rnd);
placeDisk(W * 0.38, H * 0.82, R, nstarsRef.current - 2 * per - 28, Math.PI * 0.5, 0.34, -0.62, 0.92, 2, rnd);
// 산개 별 84개 — 세 은하 사이 공간을 채워 스치는 순간 전체가 살아 난다
for (let s = 0; s < 84; s++) {
const sx = W * (0.04 + rnd() * 0.92);
const sy = H * (0.06 + rnd() * 0.88);
const sa = rnd() * Math.PI * 2;
const sv = 38 + rnd() * 34;
sim.X.push(sx); sim.Y.push(sy);
sim.VX.push(Math.cos(sa) * sv); sim.VY.push(Math.sin(sa) * sv);
sim.GID.push(s % 3);
sim.N++;
}
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.013)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
chainCtx.strokeStyle = rgba(gcol(sim.GID[i]), 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 심우주 배경 — 잉크
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
// 먼 별밭(정적·시드 고정)
const rnd = mulberry32(31);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 55; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.10 + rnd() * 0.24;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 성운 스프라이트 — 구워 둔 것을 얹는다. 아주 느린 드리프트로 심우주 배경 자체를 살린다
const nebCvs = nebRef.current;
for (let n = 0; n < nebCvs.length; n++) {
const nb = nebCvs[n];
const ph = n * 2.1;
const dx = Math.sin(simStepsRef.current * 0.0031 + ph) * W * 0.035;
const dy = Math.cos(simStepsRef.current * 0.0026 + ph * 1.7) * H * 0.045;
const sc = 1 + Math.sin(simStepsRef.current * 0.0022 + ph) * 0.10;
const sz2 = nb.sz * sc;
ctx.drawImage(nb.cv, W * nb.x + dx - sz2 * 0.5, H * nb.y + dy - sz2 * 0.5, sz2, sz2);
}
// 궤도 잔상
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하별 파스텔 3색
for (let i = 0; i < sim.N; i++) {
const c = gcol(sim.GID[i]);
ctx.fillStyle = rgba(c, 0.10);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.6);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 각 은하의 코어 — 같은 색 밝은 점(무게중심)
for (let g = 0; g < 3; g++) {
let sx = 0, sy = 0, c = 0;
for (let i2 = 0; i2 < sim.N; i2++) {
if (sim.GID[i2] !== g) continue;
sx += sim.X[i2]; sy += sim.Y[i2]; c++;
}
if (!c) continue;
ctx.fillStyle = rgba(gcol(g), 0.9);
ctx.beginPath(); ctx.arc(sx / c, sy / c, 2.2, 0, Math.PI * 2); ctx.fill();
}
}
function stillFrame(): void {
// reduce — 첫 스침이 일어난 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 200; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 4.5; // 4.5초 루프 — 스침 → 꼬리 → 합류 순서가 다 보이게
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
nebulaRef.current = nebula;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, nebula]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>성운 스프라이트를 깐 심우주 무대에 여러 은하가 차례로 스치며 합쳐지는 진화 시퀀스를 배치했습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>은하 3개 · 별 330개</span>
<span className={styles.metaDim}>canvas · nebula sprites · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · ディープフィールドシーン — 何億年も込めた銀河進化の舞台 — 計測 累積面積 30.189% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
3つの円盤を対角線に植える
円盤ごとに接近速度と回転の向きを変え、色分け配列GIDで3色を割り当てます。中心質量のない散在星を混ぜて軌跡が画面の隅まで伸びるようにします。
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, gid, rnd) {
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * R * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * R * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
GID.push(gid);
N++;
}
}
進化の順番が1ループに収まる理由
接近速度と初期距離の組み合わせで、1周4.5秒の間に2組のすれ違いと1度の合体が順に起きます。累積面積30.189%・強度52.2は星雲の下地を含まない星だけの値です。
ソフトニングを外すと1フレームで壊れる近接接触
2つの星が近づきすぎると加速度が爆発する
ニュートン重力は距離が0に近づくと発散するので、softeningなしでは2つの星が偶然重なった瞬間に1フレームで計算全体が画面の外へ飛びます。そこで全ての盤が距離の2乗にsofteningの2乗を足しており、その値が盤ごとに16〜90と異なるのは場面の性質の違いです。激しくすれ違う01・05・06は16と小さくして接近の加速を残し、星が長く留まる03は60と大きくして安定を先に確保します。
前進Eulerはエネルギーを注入する
01と03が使うsemi-implicit Eulerは1フレームの誤差こそ小さいものの、毎フレーム少しずつエネルギーを足したり奪ったりするので、数周回ると軌道が外へ膨らみます。2〜3秒のループでは目立ちませんが、長く回し続けると円盤が散ってしまうため、02と04〜09は半ステップを対称に押してエネルギーを保つleapfrogで積分します。02のMETHOD定数1行で両方を切り替えられるのは、この差を自分の目で確かめるためです。
彗星を配列に入れた瞬間にバッファの寸法がずれる
07でドラッグして新しい星を投げ込むと星数Nが増えますが、このとき加速度バッファのAXとAYは前の寸法のままです。再計算の前にこのバッファを参照すると未定義の値から始まってNaNが計算全体に広がるので、addCometは配列を伸ばした直後にaReadyを切って、次のステップでバッファを作り直させます。星を1つ足すことも状態を1つだけ触る作業ではない、という箇所です。
九つの盤の index.html と style.css が丸ごと入ったzipを展開するパスワードはhpcmbqutです。展開すれば本文に引用した関数がコメント込みでそのまま入っているので、行単位で突き合わせて確かめられます。
この9枚を実際どこに置くか — 表紙背景と体験型デモ
宇宙・科学系サイトの表紙なら09 ディープフィールドシーンをそのまま背景に敷くのが一番早い方法です。何億年もの進化をすでに1枚の構図に圧縮してあるので、見出しを重ねるだけでヒーローセクションが完成します。訪問者に実際に触ってほしい実験室やチュートリアルページには07 彗星を撃つを置くと、角度と強さをドラッグで決める操作がそのまま体験型デモになります。
軌道が積み重なる様子を見せたい物理教育コンテンツには08 軌跡ヒストリーが向いています。残像として積もる星の過去だけで、腕が固定された構造物ではないことを言葉なしに示せます。01 2つの銀河衝突はそれ自体が最も劇的な画面なので、ブログ記事や発表資料の導入カットとしてもそのまま使えます。
星の数と積分法を変える3つの変種とreduced-motion
| 名前 | 変更する値 | 変わる感触 |
|---|---|---|
| 星の数 | 01 NSTARS 340 → 120 | 統計の塊ではなく個別の軌道が浮かび上がる |
| すれ違う距離 | 01 SKIP 0.60 → 0.25 | 深く掠れば尾2本の代わりに核が貫通して散る |
| 積分器 | 02 METHOD leapfrog → euler | 数周で円盤が人工的に膨らむ差が目の前に出る |
定数ブロックの1行を書き換えるだけ
3つとも書き換えるのはソース先頭の変数ブロックの1行だけです。星の数は構造の解像度、すれ違いの距離は場面の性格、積分器は安定と精度の関係という、それぞれ別の性格のつまみです。
動きを減らす設定で残る1枚
九つの盤すべてにprefers-reduced-motion: reduceの分岐が組み込まれており、ループを回さず接近が起きた場面まで積分した1枚を固定し、キャンバスに.is-stillクラスを付けます。静止していても潮汐尾や育った腕が見えるので、動きを止めた訪問者にも各盤の結論の場面が届きます。
触れる1枚と完結する8枚
07だけがポインター入力を受け付け、ほかの8つは手を触れなくても完結します。07の発射領域には照準の状態を伝えるラベルが付き、04のハローのトグルボタンはaria-pressedで状態を示すので、キーボードと読み上げでも扱えます。
なぜ星は自然と渦巻きになるのか — 重力積分と本物の渦巻きの違い
重力の大きさは距離の2乗、方向成分は3乗で割る
毎フレームの計算の核は、距離の2乗にsofteningを足して平方根を1回取るだけの4行です。画面座標で方向成分を足すには距離の3乗で割った商にdxを掛ける形になり、数値計算の仕組みを何も使わずに済みます。Gと質量は物理定数ではなくキャンバスのピクセルに合わせたシミュレーション値で、Gは26〜140まで盤ごとに、描かれない中心質量は5400〜6200の範囲で異なります。
差動回転がゆらぎを巻き上げて腕にする
02の円盤は内側ほど速く回る円軌道速度で播種されるので、初期の小さな密度ゆらぎが速度差で引き伸ばされながら巻き上がります。腕の本数と巻き方向はシードのゆらぎが決め、計算の途中で描き込まれるものではありません。シードが決定論的な乱数で固定されているので、読み込むたびに同じ腕が育ちます。
腕は固定された構造ではなく軌道が重なる場所
08のように軌道を積み続けると、同じ腕の場所を通る星が入れ替わり続ける様子が見えます。腕は星を縛っておく紐ではなく軌道が互いに近づいて密度が一時的に高くなる領域で、個々の星は腕を通り抜けて自分の軌道をそのまま回ります。
すれ違うだけでも尾が出る潮汐力の差
潮汐尾は衝突の瞬間の出来事ではなく、すれ違っている間に星ごとに受ける引力の差が積もった結果です。相手の銀河に近い側の星は強く、遠い側の星は弱く引かれるので円盤が両側に伸び、この伸びが軌道エネルギーに変わって外へ吹き出します。06の追跡軌跡が2つの中心の間を往復して投げ出される形なのは、この差が個別軌道に残す痕跡です。
この銀河シミュレーションを組み込む前によくある質問
この銀河 シミュレーション を自分のページに置くには何から変えればいいですか?
zipを展開すると九つの項目のフォルダーが出るので、好きな盤1つの index.html と style.css の2ファイルをフォルダーごとアップロードすればそのまま動きます。色はstyle.cssの--gx-系トークンで、場面の大きさは同じファイルの.gx__fieldの高さで調整します。どの盤も他の盤を参照しないので、1つだけ持ち出しても残りは無関係です。
重力 シミュレーション サイト風のページの1場面に使えますか?
使えます。09は表紙の hero 用に構図を決めた盤で、01と08は暗い色だけで塗るので背景に敷いても本文の読みやすさを損ないません。ただし重力の計算が毎フレーム走るので、低速な端末のために星の数を減らして置くほうが安全です。
星の数を大きく増やすとどこで遅くなりますか?
ペアの数は星の数の2乗で増え、01の340個は毎フレーム57,630ペアを計算しています。星を2倍にすればペアは4倍になるので、数千個を超えるあたりでフレームが落ち始め、それ以上を目指すなら04の格子質量のような近似に道を変える分かれ目になります。









