N Body Simulation, 9 Ways (Galaxy, No Library)
No library, no WebGL — integrate Newtonian gravity on every star, every frame, and two galaxies shear past, throwing tidal tails as they merge: cosmic-scale
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Two galaxies collide — shear past, throw tidal tails, merge
- 02 Spiral arms form — a disk of stars self-organizing into arms
- 03 Globular cluster — hundreds of stars orbiting a common center of mass
- 04 Dark-matter halo — orbits change the moment invisible mass is added
- 05 Merger remnant — a spiral torn apart into a bland elliptical
- 06 Tidal tail close-up — the fling seen through one star's trail
- 07 Launch a comet — drag angle and power to throw a star in yourself
- 08 Trail history — a star's past stacking as afterimages
- 09 Deep-field scene — a stage of galactic evolution over eons
Before and After the Collision: Nine Scenes on a Timeline
The nine scenes climb one axis: how much of the gravity you actually compute. 01 is the base collision — every star pulls every other star through the full O(N²) pair sum, and the tidal tails that appear at the shear are nobody's drawing. 02 and 03 stay full pair-sum but change the stage: a still disk winding itself into spiral arms, and a sphere of stars where each one orbits on its own plane. 04 is the one approximation of the set — star masses are binned into a radial grid instead of summed pair by pair, and the freed frame budget pays for a live rotation-curve graph. 05 returns to full pairs but starts the clock after the first passage, 06 tracks a single star's orbit through the same kind of shear, 07 lets your pointer throw new stars into the same gravity field, 08 stops erasing afterimages so hundreds of orbits stack into the arms, and 09 stages three galaxies over pre-baked nebula sprites. It shares nothing with a flock bound by three neighbor rules and no shared field at all — there each particle steers by its neighbors alone, here every star feels every other star through the full pair sum — and nothing with a slime mold network grown from deposits and evaporation, where the field is a map the swarm writes for itself. A galaxy gravity simulation is compelling for the opposite reason: the field is nothing but the stars, recomputed every frame.
Code per item
Every snippet below comes straight out of that item's own script tag — constant names intact, comments translated, identifiers untouched. Eight of the nine items compute star-to-star gravity as the full pair sum; only item 04 bins mass into a grid, and its section says so out loud.
01Two galaxies collide — shear past, throw tidal tails, merge
Two disks of 170 stars each approach from opposite corners, every star carrying position and velocity in plain arrays (masses are uniform in this item, folded into G), and every frame sums all 57,630 star pairs of Newtonian acceleration — the moment the disks shear, outer stars shuttle between the two cores, lose energy, and are flung outward into tidal tails that form on their own. Nothing is drawn from a spiral formula; the tails grow out of per-star orbital integration, painted at 20.125% cumulative area — the smallest of the nine — with a 16.048% single-frame jump at the first shear and 59.3 average intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Sum Newtonian gravity on every star each frame: the moment two disks shear, tidal tails of flung stars form on their own</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Two-galaxy collision lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">340 stars · O(N²) all-pair gravity</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 · Two galaxies collide — shear past, throw tidal tails, merge — Measured area 20.125% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Every pair, once — Newton's third law applied to both stars
function step() {
// Full pair gravity — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5. Symmetric: compute each pair once, apply to both
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 is the acceleration component
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;
}
}
Seeding a disk whose edge sits at circular-orbit speed
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
// Central mass M — sized so the disk edge (r=R) sits at circular-orbit speed v=sqrt(M/R)
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd()); // uniform surface density — r² uniform
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++;
}
// One core star per galaxy center (mass shown by size — array stays uniform)
}
02Spiral arms form — a disk of stars self-organizing into arms
A still disk of 320 stars, each given the circular-orbit speed for its radius — inner stars faster, outer slower — plus an undrawn central mass MHALO=6200 and a seeded density ripple: differential rotation winds the ripple up until spiral arms sharpen by themselves. Where a spiral written as a formula paints a shape, this one is grown by integration, and the integrator is leapfrog so the disk does not inflate over many revolutions, painted at 31.310% cumulative area with a 24.639% peak at the loop-rewind moment and 48.0 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Differential rotation amplifies a seeded ripple until a still disk grows spiral arms by integration alone</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Spiral-arm formation lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">320 stars · leapfrog integration</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 · Spiral arms form — a disk of stars self-organizing into arms — Measured area 31.31% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Leapfrog — symmetric half-steps that conserve energy
function stepLeap() {
// leapfrog (velocity Verlet) — reuse a(t), push in symmetric half-steps, conserve energy
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
}
}
The ripple that differential rotation winds into arms
function seed() {
// Still disk — per-radius circular-orbit speed (inner faster = differential rotation) plus a seeded ripple.
// The ripple is a sine wave coupling angle to radius — differential rotation winds it into a spiral.
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; // outer star — wide orbit beyond the disk
var r = far
? R * (0.42 + 2.00 * rnd())
: 0.12 * R + R * 0.82 * Math.sqrt(rnd()); // dense core + thin outside
var a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // radius-coupled sine ripple
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.82 : 1.0); // circular speed — larger when r is small
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.90); // slightly squashed disk (visual depth)
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++;
}
}
03Globular cluster — hundreds of stars orbiting a common center of mass
280 stars scattered at uniform density through a 3D sphere, integrated under full O(N²) 3D gravity — the line-of-sight z enters the computation directly, and the screen is merely a perspective projection at FOCAL=420. With every star at its own angle, differential rotation cannot form arms: each star orbits the live-recomputed center of mass on its own orbital plane, and the whole cluster slowly tumbles, at 22.827% cumulative area, a gentle 6.406% peak, 49.4 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Stars scattered in a sphere orbit the common center at their own angles — unlike a disk, they never collapse inward</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Globular cluster lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">280 stars · live center of mass</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 · Globular cluster — hundreds of stars orbiting a common center of mass — Measured area 22.827% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The same pair sum, with dz added
function step() {
// Full pair O(N²) 3D gravity — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (no approximation)
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;
}
}
04Dark-matter halo — orbits change the moment invisible mass is added
The one approximate item of the nine: star masses are not summed pair by pair here — every step, each star's mass is accumulated into one of K=28 radial bins to build the enclosed spherical mass M(r), and each star is pulled toward the center by the mass inside its radius, an isotropic approximation that discards directional information. The halo toggle overlays an invisible mass profile ρ ∝ 1/(r²+rc²) on the same grid, inflating M(r) so outer stars keep their circular speed — the flat rotation curve, graphed live top-right — and the freed frame budget is what pays for that graph, at 37.479% cumulative area, the largest of the nine, 12.855% peak, 50.5 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Toggle an invisible mass halo on and outer stars keep their speed instead of falling off — why galaxy edges orbit too fast</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Dark-matter halo lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
<button class="gx__halo" id="haloBtn" type="button" aria-pressed="false">
<span id="haloOffLbl">halo off</span><span id="haloOnLbl" hidden>halo on</span>
</button>
</div>
<div class="gx__bar">
<span class="gx__meta">300 stars · grid mass approximation</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();
}
// halo on — 보이지 않는 질량의 반경 스케일을 옅은 링으로만 암시한다
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 · Dark-matter halo — orbits change the moment invisible mass is added — Measured area 37.479% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The invisible mass profile, integrated into bins once
function buildHalo() {
// Halo profile — a fixed distribution (cold dark matter that never collides with stars).
// ρ ∝ 1/(r²+rc²) integrated into grid bins and stored (computed once)
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 approximated
}
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;
}
Each star pulled inward by the enclosed mass only
// Each star's acceleration — pulled toward the center by the enclosed mass M(r)
// (isotropic approximation: directional information is discarded)
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; // bin midpoint approximation
var d3 = Math.pow(r * r + SOFT2, 1.5);
var f = G * Mr / d3;
AX[i] = dx * f; AY[i] = dy * f;
}
}
05Merger remnant — a spiral torn apart into a bland elliptical
The start is not the first passage but just before the second: the seed places the two disks close together and pre-integrates PREPASS=420 leapfrog steps, so the first shear and its tails are already in the past when on-screen time begins. As time flows, orbit directions mix — virialization — and the spiral structure scatters into a smooth featureless ellipsoid: the final state of two spiral galaxies, derived by integration rather than drawn, at 32.382% cumulative area with the smallest peak of the nine at 4.899%, which is expected when a scene starts late and evolves gently, 48.4 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Let time run past the collision: arms scatter, orbit directions mix, a smooth ellipsoid remains</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Merger remnant lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">starts at second passage · 320 stars</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 · Merger remnant — a spiral torn apart into a bland elliptical — Measured area 32.382% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Starting the clock after the first passage
function seed() {
// Place the two disks just before the second passage (close together) and pre-integrate
// PREPASS steps — on-screen t=0 already shows tails lined out from the first shear
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();
}
06Tidal tail close-up — the fling seen through one star's trail
The computation is the same full O(N²) pair sum as item 01 — the same kind of shear on a slightly different seed, integrated with leapfrog instead of Euler — no approximation, and only the rendering differs: the crowd is faint dots with short afterimages, while one tracked star, planted at a disk edge, accumulates a thick amber trail in a 180-coordinate ring buffer and draws it segment by segment, oldest faintest. The individual orbit shuttles between the two cores, Lissajous-like, then is flung out by the tidal kick — the tail explained as one star's story, not a statistic, at 20.762% cumulative area, 6.303% peak, 55.0 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Trail just one star among thousands: the moment the tail forms, it shuttles between the two cores and is flung out</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Tidal tail close-up lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">340 stars · one tracked orbit</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 · Tidal tail close-up — the fling seen through one star's trail — Measured area 20.762% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One trail, fading from old to new
// Tracked trail — thick amber line. Older segments drawn fainter (fade)
var tn = trailX.length;
if (tn > 1) {
for (var s = 1; s < tn; s++) {
var age = s / tn; // 0 old → 1 newest
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();
}
}
07Launch a comet — drag angle and power to throw a star in yourself
The stage is a disk galaxy — an undrawn central mass MHALO=5400 plus 180 stars — and your pointer is a slingshot: drag from any point, release, and the new star is appended to the same arrays with no special treatment, joining the same O(N²) gravity field as-is. By the angle and speed of your throw it settles into an elliptical orbit or escapes on a hyperbola, and the render auto-launches comets at fixed deterministic angles — up to five in flight at once — so the panel is alive unattended, at 26.731% cumulative area, 6.271% peak, 55.3 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Drag from the galaxy to throw a star in — by the angle it settles into orbit or escapes outright</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Launch a comet lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
<span class="gx__aim" id="aimLbl" hidden>drag to launch</span>
</div>
<div class="gx__bar">
<span class="gx__meta">180 stars · central attracting mass</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 = 'drag to launch';
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 · Launch a comet — drag angle and power to throw a star in yourself — Measured area 26.731% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Pull back, release, and the star joins the arrays
field.addEventListener('pointerup', function (e) {
if (!aim) return;
var p = ptr(e);
var dx = aim.x0 - p.x, dy = aim.y0 - p.y; // opposite of the pull = launch direction
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;
});
08Trail history — a star's past stacking as afterimages
Where items 01 through 07 erased a little each frame, this one removes decay entirely and only lays prior frames down with globalAlpha — three alpha layers per step, LAYERS=3 — so orbits keep stacking until the spiral arms reveal themselves as an accumulation of hundreds of orbits rather than a single snapshot, which is the scene's whole point. Computation is the same full pair sum plus central mass and leapfrog as item 02, and the result shines at 62.6 average intensity, the brightest of the nine, at 32.485% cumulative area and 12.053% peak.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>Lay prior frames down with alpha and orbits accrete into soft lines — arms are accumulations of orbits, not a snapshot</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Trail history lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">300 stars · afterimage layer</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 · Trail history — a star's past stacking as afterimages — Measured area 32.485% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
No erasing — just alpha layers, stacked
function stamp() {
// Accumulation — lay low-globalAlpha layers each step and orbits harden into lines
if (prev) {
for (var L = 0; L < LAYERS; L++) { // LAYERS deep — a stacking-speed variant
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(); }
}
09Deep-field scene — a stage of galactic evolution over eons
The closing scene: three nebula sprites — periwinkle, rose, amber — are pre-baked on separate canvases with createRadialGradient and composited each frame with drawImage, so the 330-star computation keeps its frame budget; on top, three galaxies of 82 stars each plus 84 strays filling the field, one pastel hue per galaxy, are seeded on crossing diagonal orbits, and the evolution sequence of shear → tidal tail → merger plays out in order within one loop, full pair sum plus leapfrog, no approximation, at 30.189% cumulative area, 7.303% peak, 52.2 intensity.

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>On a nebula-sprinkled deep field, several galaxies graze and merge in an evolution sequence</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="Deep-field evolution lab" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">3 galaxies · 330 stars</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 · Deep-field scene — a stage of galactic evolution over eons — Measured area 30.189% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Nebula sprites baked once, not rebuilt per frame
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 });
}
}
Where these nine actually land on a real page
A space or science-themed landing page reaches for item 09 first — the deep-field scene already compresses eons of evolution into one composition, so it drops in as a hero background with nothing more than a headline on top. A lab or tutorial page built for visitors who like to poke at things does better with item 07: dragging an angle and a power to throw a star in turns the simulation into a hands-on demo instead of a passive backdrop.
For a physics write-up that wants to show orbits accumulating rather than just moving, item 08's trail history does the explaining for you — the afterimages alone show why an arm is a traffic pattern, not a fixed structure. Item 01's head-on collision is dramatic enough on its own to open a blog post or a slide deck without any surrounding text.
Skip the softening term and stars fling off in a single frame, breaking the scene
Pure 1/r² with no softening flings stars off-screen in one frame
Set ε² to zero and the first near-encounter produces an acceleration that scales as the inverse square of a sub-pixel distance — the two stars involved leave the canvas in a single step, and after a few frames the scene is empty except for a shrinking knot. The softening constants here range from 16 to 90 depending on star size and expected closeness; they are not decoration, they are what makes an n body simulation online-runnable at all. Item 04 pushes softening furthest at ε²=90 precisely because its radial-bin gravity concentrates mass at the center.
Forward Euler spirals the disk apart
Item 01 ships semi-implicit Euler and stays stable inside its 2.0s loop — but give it a longer loop and the disk slowly inflates, because the integrator adds a little energy every step. The three-variants table at the bottom makes this a feature: switch item 02's METHOD from leapfrog to euler and within a few revolutions the disk visibly puffs, the difference between conserving and not conserving energy landing right in front of you.
Math.pow per pair is visibly slow at 58k pairs per frame
The inner loop computes d2 = dx·dx + dy·dy + SOFT2, takes inv = 1/sqrt(d2) once, then multiplies inv by itself twice for f = G·inv³ — three multiplies. Writing Math.pow(d2, 1.5) or computing Math.hypot per pair instead is measurably slower at 60fps with 340 stars, and the stutter appears exactly when two galaxies overlap and every pair is live. The same discipline is why the comets of item 07 are appended to the star arrays instead of being tracked separately — one loop, one shape of arithmetic.
clearRect every frame deletes the tidal tails
The afterimage canvas is the memory of the orbits — erase the main canvas each frame and redraw from stored points, and you get a polyline, not ink. Items 01 through 07 erase a little each frame with destination-out so tails fade as they age; item 08 erases nothing and keeps stacking with globalAlpha, which is why it posts the highest average intensity of the nine at 62.6. For a galaxy simulation python or canvas implementation alike, the lesson is the same: structures like tidal tails and spiral arms are accumulations of orbits, and a per-frame clear hides exactly the structure the computation exists to produce.
Three collision variants, tuned for reduced-motion
One constant, three different collisions
| Name | Change | Feel |
|---|---|---|
| Star count | 01 NSTARS 340 → 120 | Individual orbits emerge where statistical structure used to be |
| Closest approach | 01 SKIP 0.60 → 0.25 (head-on) | The cores punch through and stars scatter explosively, no twin tails |
| Integrator | 02 METHOD leapfrog → euler | Within a few revolutions the disk visibly inflates before your eyes |
Reduced motion still shows the tidal tails
Every item checks matchMedia('(prefers-reduced-motion: reduce)') before starting the requestAnimationFrame loop, and for those visitors the loop never runs — instead each scene is pre-integrated to mid-evolution and one frozen frame is stamped with an .is-still class. The tails, the wound arms, and the first launched comets are all there, complete and legible, just not moving.
One drag to make, eight panels that run themselves
Only item 07 wants a pointer and only item 04 wants a click — every other panel runs unattended on requestAnimationFrame, and each canvas host carries an aria-label and a tabindex so the whole page is reachable from the keyboard. Nothing here was retyped or shortened: hpcmbqut unlocks the same nine source files the snippets came from, HTML, CSS, and JS together, inside this page's zip.
Why do stars self-organize into spirals? — gravity integration vs. a true spiral
A spiral formula draws a shape; integration grows one
Nothing in these scenes knows what a spiral arm is. The disk of item 02 is seeded with positions and velocities only — a radius-coupled sine ripple in the angles, circular speeds that fall off with radius. Inner stars complete a revolution in less time than outer stars, so any overdense ridge in the seed is sheared: the ridge's inner end runs ahead, its outer end lags, and after a few revolutions the ridge has been wound into a trailing spiral. The arm is a traffic pattern, not an object — stars pass through it continuously, the way a wave in a stadium is made of different people at every moment. A formula-drawn spiral, by contrast, has no stars at all; delete the formula and the picture is gone, while here deleting the seed ripple only makes the arms start later, because gravity and differential rotation keep doing the winding.
The pair sum, the softening, and the integrator are the whole physics
The acceleration a_i = Σ G·(x_j − x_i)/(|d|²+ε²)^1.5 is Newtonian gravity with one numerical stabilizer: the softening ε², which keeps two close-approaching stars from diverging to infinite acceleration in a single frame. The pair loop exploits Newton's third law — compute each pair once, apply it to both stars with opposite signs — which halves the work and makes the total momentum exactly conserved by construction. The integrator choice is what keeps the picture honest over time: forward Euler pumps energy in and orbits spiral outward, semi-implicit Euler (velocity first, then position) is the minimum viable fix used by items 01 and 03, and leapfrog — used by 02 and by 04 through 09 — pushes velocity in symmetric half-steps so the disk stays un-inflated over many revolutions. The same energy-conservation arithmetic is what lets a lightning bolt fork and die by one field's collapse hold its shape — except there the field is rebuilt each frame from scratch, and here everything persists: position, velocity, and the accumulated afterimage canvas that remembers where each star has been. A galaxy simulation 3d stage is only honest if the third axis is real, which is why item 03 integrates z directly and projects afterward rather than faking depth with scale alone.
Questions before you wire this n-body sim into a page
Is an n body simulation like this one actually full gravity, or is it faked
Eight of the nine panels compute the genuine full pair sum — every star pulls every other star, every frame, with no shortcuts. Only item 04, the dark-matter halo, approximates: it bins star mass into 28 radial cells and pulls each star inward by the enclosed mass M(r) instead of summing pairs, because that scene needs its frame budget for the live rotation-curve graph. The distinction is stated in the item's own comments and never quietly mixed into the other scenes — the price of the approximation is directional information, and pretending it is free would be a lie the render exposes anyway.
Can I run a galaxy simulation online in my own page without a library
Yes — that is the entire premise of the zip. Every scene is one HTML file plus one style.css of tokens, vanilla JavaScript on a canvas 2D context: no WebGL, no CDN, no physics library, no Barnes-Hut tree code. At the star counts used here (up to 340, about 58 thousand pairs per frame) the plain double loop holds 60fps on an ordinary laptop, and seeding with a deterministic PRNG (mulberry32) means the same galaxies render on the same orbits every visit, which is what makes any screenshot of these panels reproducible. The n body simulation code in each section is the exact source the panels above run.
Why does the dark-matter item paint the largest area of the nine
Because its loop runs longest and its stars never leave. Item 04 cycles over 6.0 seconds — the longest loop of the set, and roughly twice most of the others — and its halo-on state holds outer stars at their circular speed instead of letting them drift, so ink keeps landing inside the frame until the cumulative painted area reaches 37.479%. The runner-up, item 08 at 32.485%, gets there the opposite way: a 5-second loop whose afterimages never erase, stacking orbit on orbit. The two numbers together show that painted area measures interaction between motion and memory — fast changes with fading ink, or slow changes with permanent ink, both fill a canvas.
Sources: MDN — CanvasRenderingContext2D · MDN — Window.requestAnimationFrame() · MDN — Math.hypot() · MDN — prefers-reduced-motion · Wikipedia — N-body problem · Wikipedia — Galaxy collision









