이중 진자 시뮬레이션 9가지 — 오차 하나가 길을 가르는 카오스 장면
출발 각도를 100만분의 1 라디안만 바꿔도 진자 끝은 전혀 다른 길을 그린다 — 03번은 두 진자를 겹쳐 그 갈림길을 눈앞에 보여 준다. 아래 아홉 장면은 이중 진자를 캔버스 2d 위에서 프레임마다 계산해 그린 것이고, 파일을 내려받으면 그대로 켜진다.
이중 진자 아홉 장면을 화면에서 바로 돌려 본다
01 dp-rk4-two-arm-step-lagrangian
02 dp-fading-ink-trail-tip-path
03 dp-twin-divergence-tiny-offset
04 dp-phase-space-angle-plot-trace
05 dp-energy-drift-meter-euler-vs-rk4
06 dp-sixteen-fan-ensemble-hue-spread
07 dp-drag-arm-set-release-angles
08 dp-flip-time-color-map-fractal-grid
09 dp-hero-brass-desk-toy-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 두 팔의 각가속도를 풀어 한 걸음씩 나아가는 진자
- 02 끝 추가 궤적이 잉크처럼 옅어지는 흔적
- 03 머리카락 한 올 차이로 갈라서는 쌍둥이 진자
- 04 두 각도로 그리는 위상 평면 궤적
- 05 계산법마다 에너지가 어긋나는 크기를 재는 비교 계기
- 06 열여섯 진자가 부채꼴로 퍼지는 색 띠
- 07 팔을 끌어다 놓아 출발 각도를 정하는 진자
- 08 뒤집히는 데 걸린 시간을 색으로 칠한 출발각 지도
- 09 황동 책상 장난감 위에 발광 궤적을 남기는 히어로
이중 진자 시뮬레이션 9가지 — 적분 한 단계가 카오스의 모양을 정한다
아홉 장면의 뿌리는 같은 계산 한 벌이다. 줄 하나에 줄이 하나 더 매달린 두 팔의 각도를 accTheta1·accTheta2 라는 식 둘로 가속도로 바꾸고, rk4Step 이 룽게-쿠타 네 단계로 한 걸음씩 앞으로 보낸다. 맨 끝에 달린 공을 이 글에서는 끝 추라 부른다. 팔 길이 L1·L2 와 질량 M1·M2 는 전부 1.0, 중력 G 는 9.81 이고, 각도는 아래 방향이 0 이다. 난수도 Date.now 도 쓰지 않아서 같은 출발 각도에서는 언제나 같은 길이 나온다.
01 이 그 뼈대를 그대로 보이고, 02 는 진자 끝이 지나온 길을, 04 는 두 각도를 한 장의 평면에 그린다. 03·06 은 출발 각도를 아주 조금씩 다르게 한 진자를 여럿 겹치고, 05 는 계산법을 둘 비교하며, 07 은 사람이 출발 각도를 정한다. 08 은 출발 각도 전체를 지도로 칠하고, 09 는 이 모두를 책상 위 황동 장난감 한 장면으로 묶는다.
이 장면들은 화면에서 실시간보다 빠르게 흐른다. 프레임마다 SUBSTEPS 걸음을 DT 0.01 초씩 진행하므로 프레임당 0.03초(03 은 0.04초)이고, 고정 시간 누적기가 16 밀리초마다 한 프레임을 내므로 실시간의 약 2배(03 은 약 2.5배) 속도다. 라이브러리·WebGL·외부 주소는 하나도 쓰지 않았다. 비슷한 물리 장면이 궁금하면 바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산과 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면을 같이 보면 된다.
기법별 코드로 보는 진자 계산
아래 조각은 데모 소스에서 연속된 줄을 순서 그대로 잘라 붙였다. 공통 식(accTheta1·accTheta2·rk4Step)은 아홉 장면이 같아서 01 에서 한 번만 쓰고 나머지에서는 장면마다 다른 줄을 골랐다. 완결된 파일은 압축 파일에 들어 있다.
01두 팔의 각가속도를 풀어 한 걸음씩 나아가는 진자
stepFrame() 이 프레임마다 SUBSTEPS 번 rk4Step(sim, DT) 를 부르고, 걸음이 끝난 자리에서 accTheta1·accTheta2 를 다시 불러 두 가속도(accA·accB)를 얻는다. 위쪽 두 막대가 이 가속도를 ±90 rad/s² 눈금으로 보이고, 끝 추가에는 72점짜리 궤적이 따라붙는다. 이 장면은 누적면적 62.4%·평균 강도 34.1·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="두 팔의 각가속도를 라그랑주 식으로 풀고 룽게-쿠타 네 단계로 한 프레임을 세 걸음에 나눠 적분해, 끝 추가가 정확히 따라오는 기본 진자를 그린다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">두 팔의 각가속도를 라그랑주 식으로 풀고 룽게-쿠타 네 단계로 한 프레임을 세 걸음에 나눠 적분해, 끝 추가가 정확히 따라오는 기본 진자를 그린다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 두 각도의 가속도를 라그랑주 식으로 풀고, 한 프레임을 SUBSTEPS 걸음으로 쪼개 룽게-쿠타로 적분한다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.2;
var START_T2 = 2.6;
var TRAIL_LEN = 72;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 38, PIVOT_X = 160, PIVOT_Y = 74;
var PREROLL_FRAMES = 30;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var sim = [START_T1, START_T2, 0, 0];
var accA = 0, accB = 0;
var trail = [];
function stepFrame() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
}
accA = accTheta1(sim[0], sim[1], sim[2], sim[3]);
accB = accTheta2(sim[0], sim[1], sim[2], sim[3]);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
function meter(y, v, rgb) {
var half = 52;
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(14, y, half * 2, 6);
ctx.fillStyle = 'rgba(' + rgb + ',0.95)';
var f = clamp(v / 90, -1, 1) * half;
ctx.fillRect(14 + half + (f < 0 ? f : 0), y, Math.abs(f), 6);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillRect(14 + half - 0.5, y - 2, 1, 10);
}
function draw() {
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#4a416e', '#28223f', b.x, b.y);
glow(b.x, b.y, 96, '255,238,252', 0.3);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, UNIT * (L1 + L2), 0, 6.2832);
ctx.stroke();
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, UNIT * L1, 0, 6.2832);
ctx.stroke();
ctx.setLineDash([]);
drawTrail(trail, '255,179,199', 4, 0.8);
drawArms(sim, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 5);
seg(PIVOT_X, PIVOT_Y, a.x, a.y, 1.5, 'rgba(255,255,255,0.55)');
seg(a.x, a.y, b.x, b.y, 1.5, 'rgba(255,255,255,0.55)');
disc(PIVOT_X, PIVOT_Y, 5, '#fff1c9');
glow(a.x, a.y, 20, '201,184,255', 0.55);
disc(a.x, a.y, 8, '#c9b8ff');
glow(b.x, b.y, 26, '255,179,199', 0.65);
disc(b.x, b.y, 10, '#ffb3c7');
disc(b.x - 3, b.y - 3, 3, 'rgba(255,255,255,0.8)');
meter(30, accA, '201,184,255');
meter(42, accB, '159,216,255');
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpRk4TwoArmStepLagrangian.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 두 각도의 가속도를 라그랑주 식으로 풀고, 한 프레임을 SUBSTEPS 걸음으로 쪼개 룽게-쿠타로 적분한다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.2;
var START_T2 = 2.6;
var TRAIL_LEN = 72;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 38, PIVOT_X = 160, PIVOT_Y = 74;
var PREROLL_FRAMES = 30;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var sim: number[] = [START_T1, START_T2, 0, 0];
var accA = 0, accB = 0;
var trail: Pt[] = [];
function stepFrame(): void {
for (var i = 0; i < SUBSTEPS; i += 1) { rk4Step(sim, DT); }
accA = accTheta1(sim[0], sim[1], sim[2], sim[3]);
accB = accTheta2(sim[0], sim[1], sim[2], sim[3]);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
function meter(y: number, v: number, rgb: string): void {
var half = 52;
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(14, y, half * 2, 6);
ctx.fillStyle = 'rgba(' + rgb + ',0.95)';
var f = clamp(v / 90, -1, 1) * half;
ctx.fillRect(14 + half + (f < 0 ? f : 0), y, Math.abs(f), 6);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillRect(14 + half - 0.5, y - 2, 1, 10);
}
function draw(): void {
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#4a416e', '#28223f', b.x, b.y);
glow(b.x, b.y, 96, '255,238,252', 0.3);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, UNIT * (L1 + L2), 0, 6.2832);
ctx.stroke();
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, UNIT * L1, 0, 6.2832);
ctx.stroke();
ctx.setLineDash([]);
drawTrail(trail, '255,179,199', 4, 0.8);
drawArms(sim, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 5);
seg(PIVOT_X, PIVOT_Y, a.x, a.y, 1.5, 'rgba(255,255,255,0.55)');
seg(a.x, a.y, b.x, b.y, 1.5, 'rgba(255,255,255,0.55)');
disc(PIVOT_X, PIVOT_Y, 5, '#fff1c9');
glow(a.x, a.y, 20, '201,184,255', 0.55);
disc(a.x, a.y, 8, '#c9b8ff');
glow(b.x, b.y, 26, '255,179,199', 0.65);
disc(b.x, b.y, 10, '#ffb3c7');
disc(b.x - 3, b.y - 3, 3, 'rgba(255,255,255,0.8)');
meter(30, accA, '201,184,255');
meter(42, accB, '159,216,255');
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpRk4TwoArmStepLagrangian() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="두 팔의 각가속도를 라그랑주 식으로 풀고 룽게-쿠타 네 단계로 한 프레임을 세 걸음에 나눠 적분해, 끝 추가가 정확히 따라오는 기본 진자를 그린다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>두 팔의 각가속도를 라그랑주 식으로 풀고 룽게-쿠타 네 단계로 한 프레임을 세 걸음에 나눠 적분해, 끝 추가가 정확히 따라오는 기본 진자를 그린다.</p>
</div>
</main>
);
}01 · 두 팔의 각가속도를 풀어 한 걸음씩 나아가는 진자 — 측정 누적면적 62.437% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var sim = [START_T1, START_T2, 0, 0];
var accA = 0, accB = 0;
var trail = [];
function stepFrame() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
}
accA = accTheta1(sim[0], sim[1], sim[2], sim[3]);
accB = accTheta2(sim[0], sim[1], sim[2], sim[3]);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
02끝 추가 궤적이 잉크처럼 옅어지는 흔적
걸음마다 끝 추가의 위치와 속도 sp 를 ink 배열에 쌓고 TRAIL_MAX 560 점이 넘으면 오래된 점부터 버리며, 그릴 때는 선분마다 (i / ink.length) 의 INK_FADE_POWER(1.6) 제곱을 알파와 굵기에 곱해 오래된 선일수록 가늘고 투명하게 하고 색상은 속도 sp 가 큰 구간일수록 더 많이 옮겨 간다. 150프레임을 미리 돌려 두므로 켜는 순간 이미 궤적이 쌓여 있다. 누적면적 62.4%·평균 강도 31.4·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="끝 추가의 위치를 걸음마다 쌓고 오래된 점일수록 가늘고 투명하게 그려, 진자가 지나온 길이 잉크 선처럼 이어지다 옅어지게 한다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">끝 추가의 위치를 걸음마다 쌓고 오래된 점일수록 가늘고 투명하게 그려, 진자가 지나온 길이 잉크 선처럼 이어지다 옅어지게 한다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 끝 추가의 위치를 걸음마다 배열에 쌓고, 오래된 점일수록 투명하고 가늘게 그려 잉크처럼 옅어지게 한다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.3;
var START_T2 = 2.0;
var TRAIL_MAX = 560;
var INK_FADE_POWER = 1.6;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 44, PIVOT_X = 160, PIVOT_Y = 84;
var PREROLL_FRAMES = 150;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var sim = [START_T1, START_T2, 0, 0];
var ink = [];
var armTrail = [];
function stepFrame() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
var sp = Math.abs(sim[3]) + Math.abs(sim[2]) * 0.5;
ink.push({ x: p.x, y: p.y, sp: sp });
if (ink.length > TRAIL_MAX) {
ink.shift();
}
}
var q = bobOne(sim, PIVOT_X, PIVOT_Y);
pushTrail(armTrail, q.x, q.y, 90);
}
function draw() {
var b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#3f385f', '#251f3b', b.x, b.y);
glow(b.x, b.y, 80, '255,241,201', 0.2);
ctx.lineCap = 'round';
for (var i = 1; i < ink.length; i += 1) {
var f = Math.pow(i / ink.length, INK_FADE_POWER);
ctx.beginPath();
ctx.moveTo(ink[i - 1].x, ink[i - 1].y);
ctx.lineTo(ink[i].x, ink[i].y);
ctx.strokeStyle = hsl(250 + ink[i].sp * 7 + i * 0.12, 78, 80, 0.9 * f);
ctx.lineWidth = 0.5 + 3.4 * f;
ctx.stroke();
}
drawTrail(armTrail, '201,184,255', 2, 0.4);
drawArms(sim, PIVOT_X, PIVOT_Y, 'rgba(201,184,255,0.9)', 'rgba(159,216,255,0.9)', 3);
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
disc(a.x, a.y, 5, '#c9b8ff');
glow(b.x, b.y, 18, '255,255,255', 0.8);
disc(b.x, b.y, 4, '#ffffff');
ctx.beginPath();
ctx.arc(b.x, b.y, 8, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpFadingInkTrailTipPath.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 끝 추가의 위치를 걸음마다 배열에 쌓고, 오래된 점일수록 투명하고 가늘게 그려 잉크처럼 옅어지게 한다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.3;
var START_T2 = 2.0;
var TRAIL_MAX = 560;
var INK_FADE_POWER = 1.6;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 44, PIVOT_X = 160, PIVOT_Y = 84;
var PREROLL_FRAMES = 150;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
type InkPt = { x: number; y: number; sp: number };
var sim: number[] = [START_T1, START_T2, 0, 0];
var ink: InkPt[] = [];
var armTrail: Pt[] = [];
function stepFrame(): void {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
var sp = Math.abs(sim[3]) + Math.abs(sim[2]) * 0.5;
ink.push({ x: p.x, y: p.y, sp: sp });
if (ink.length > TRAIL_MAX) { ink.shift(); }
}
var q = bobOne(sim, PIVOT_X, PIVOT_Y);
pushTrail(armTrail, q.x, q.y, 90);
}
function draw(): void {
var b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#3f385f', '#251f3b', b.x, b.y);
glow(b.x, b.y, 80, '255,241,201', 0.2);
ctx.lineCap = 'round';
for (var i = 1; i < ink.length; i += 1) {
var f = Math.pow(i / ink.length, INK_FADE_POWER);
ctx.beginPath();
ctx.moveTo(ink[i - 1].x, ink[i - 1].y);
ctx.lineTo(ink[i].x, ink[i].y);
ctx.strokeStyle = hsl(250 + ink[i].sp * 7 + i * 0.12, 78, 80, 0.9 * f);
ctx.lineWidth = 0.5 + 3.4 * f;
ctx.stroke();
}
drawTrail(armTrail, '201,184,255', 2, 0.4);
drawArms(sim, PIVOT_X, PIVOT_Y, 'rgba(201,184,255,0.9)', 'rgba(159,216,255,0.9)', 3);
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
disc(a.x, a.y, 5, '#c9b8ff');
glow(b.x, b.y, 18, '255,255,255', 0.8);
disc(b.x, b.y, 4, '#ffffff');
ctx.beginPath();
ctx.arc(b.x, b.y, 8, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpFadingInkTrailTipPath() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="끝 추가의 위치를 걸음마다 쌓고 오래된 점일수록 가늘고 투명하게 그려, 진자가 지나온 길이 잉크 선처럼 이어지다 옅어지게 한다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>끝 추가의 위치를 걸음마다 쌓고 오래된 점일수록 가늘고 투명하게 그려, 진자가 지나온 길이 잉크 선처럼 이어지다 옅어지게 한다.</p>
</div>
</main>
);
}02 · 끝 추가 궤적이 잉크처럼 옅어지는 흔적 — 측정 누적면적 62.405% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepFrame() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
var sp = Math.abs(sim[3]) + Math.abs(sim[2]) * 0.5;
ink.push({ x: p.x, y: p.y, sp: sp });
if (ink.length > TRAIL_MAX) {
ink.shift();
}
}
var q = bobOne(sim, PIVOT_X, PIVOT_Y);
pushTrail(armTrail, q.x, q.y, 90);
}
ctx.lineCap = 'round';
for (var i = 1; i < ink.length; i += 1) {
var f = Math.pow(i / ink.length, INK_FADE_POWER);
ctx.beginPath();
ctx.moveTo(ink[i - 1].x, ink[i - 1].y);
ctx.lineTo(ink[i].x, ink[i].y);
ctx.strokeStyle = hsl(250 + ink[i].sp * 7 + i * 0.12, 78, 80, 0.9 * f);
ctx.lineWidth = 0.5 + 3.4 * f;
ctx.stroke();
}
03머리카락 한 올 차이로 갈라서는 쌍둥이 진자
restart() 가 진자 B 의 첫째 각도에만 OFFSET 1e-6 을 더해 출발시키고, advance() 가 둘을 같은 걸음으로 진행한 뒤 끝 추가 사이 거리 sepNow 를 UNIT 단위로 쌓아 로그 눈금 그래프(logLevel)에 그리며 거리가 SPLIT_DIST 0.1 을 넘는 프레임에 상태 줄을 「두 진자가 갈라졌다」 로 바꾼다. 같은 식으로 계산하면 이 시각은 시뮬레이션 8.47초인데, 켜자마자 보이려고 PREROLL_FRAMES 150프레임(6초 분량)을 미리 돌려 그래프에 쌓고 프레임당 0.04초(SUBSTEPS 4)씩 진행하므로 화면에서는 켠 뒤 곧 갈라지고, SIM_RESET_FRAMES 420 프레임에서 처음부터 다시 한다. 누적면적 62.9%·평균 강도 36.3·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="출발 각도를 백만분의 일만 다르게 한 두 진자를 겹쳐 돌리고 끝 추가 사이 거리를 로그 눈금 그래프로 그려, 하나로 보이던 둘이 갈라서는 순간을 보인다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">출발 각도를 백만분의 일만 다르게 한 두 진자를 겹쳐 돌리고 끝 추가 사이 거리를 로그 눈금 그래프로 그려, 하나로 보이던 둘이 갈라서는 순간을 보인다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 출발 각도가 OFFSET 만 다른 두 진자를 같은 식으로 적분하고, 두 끝 추가 사이 거리를 로그 눈금으로 쌓는다
var DT = 0.01;
var SUBSTEPS = 4;
var START_T1 = 2.2;
var START_T2 = 2.6;
var OFFSET = 1e-6;
var SPLIT_DIST = 0.1;
var SIM_RESET_FRAMES = 420;
var TRAIL_LEN = 110;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 38, PIVOT_X = 112, PIVOT_Y = 78;
var PREROLL_FRAMES = 150;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var simA = [START_T1, START_T2, 0, 0];
var simB = [START_T1 + OFFSET, START_T2, 0, 0];
var trailA = [];
var trailB = [];
var sepHist = [];
var frameCount = 0;
var splitFrame = -1;
var sepNow = OFFSET;
function advance() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(simA, DT);
rk4Step(simB, DT);
}
var pa = bobTwo(simA, PIVOT_X, PIVOT_Y), pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
pushTrail(trailA, pa.x, pa.y, TRAIL_LEN);
pushTrail(trailB, pb.x, pb.y, TRAIL_LEN);
sepNow = Math.hypot(pa.x - pb.x, pa.y - pb.y) / UNIT;
sepHist.push(sepNow);
frameCount += 1;
if (splitFrame < 0 && sepNow > SPLIT_DIST) {
splitFrame = frameCount;
setStatus('두 진자가 갈라졌다');
}
}
function restart() {
simA = [START_T1, START_T2, 0, 0];
simB = [START_T1 + OFFSET, START_T2, 0, 0];
trailA = [];
trailB = [];
sepHist = [];
frameCount = 0;
splitFrame = -1;
setStatus('시연 중');
for (var i = 0; i < PREROLL_FRAMES; i += 1) {
advance();
}
}
function stepFrame() {
advance();
if (frameCount >= SIM_RESET_FRAMES) {
restart();
}
}
function logLevel(d) { return clamp((Math.log(Math.max(d, 1e-9)) / Math.LN10 + 8) / 8.4, 0, 1); }
function draw() {
var pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
var warm = splitFrame >= 0 ? clamp((frameCount - splitFrame) / 30, 0, 1) : 0;
beginDraw(warm > 0.5 ? '#5a4160' : '#463e68', warm > 0.5 ? '#2f2238' : '#2a2442', pb.x, pb.y);
glow(pb.x, pb.y, 90, '159,216,255', 0.24);
drawTrail(trailB, '159,216,255', 4, 0.85);
drawTrail(trailA, '255,179,199', 3, 0.85);
drawArms(simB, PIVOT_X, PIVOT_Y, 'rgba(159,216,255,0.85)', 'rgba(159,216,255,0.85)', 6);
drawArms(simA, PIVOT_X, PIVOT_Y, '#ffb3c7', '#ffb3c7', 2.5);
var pa = bobTwo(simA, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
glow(pb.x, pb.y, 22, '159,216,255', 0.7);
disc(pb.x, pb.y, 7, '#9fd8ff');
glow(pa.x, pa.y, 18, '255,179,199', 0.7);
disc(pa.x, pa.y, 5, '#ffb3c7');
if (splitFrame >= 0 && frameCount - splitFrame < 40) {
var f = (frameCount - splitFrame) / 40;
ctx.beginPath();
ctx.arc(pb.x, pb.y, 10 + f * 40, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,241,201,' + (0.9 * (1 - f)) + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
// 두 끝 추가 사이 거리 — 세로 로그 눈금
var cx = 196, cy = 12, cwid = 116, chei = 54;
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(cx, cy, cwid, chei);
ctx.strokeStyle = 'rgba(255,241,201,0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
var ty = cy + chei - logLevel(SPLIT_DIST) * chei;
ctx.beginPath();
ctx.moveTo(cx, ty);
ctx.lineTo(cx + cwid, ty);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
for (var i = 0; i < sepHist.length; i += 1) {
var px = cx + i / SIM_RESET_FRAMES * cwid;
var py = cy + chei - logLevel(sepHist[i]) * chei;
if (i === 0) {
ctx.moveTo(px, py);
}
else {
ctx.lineTo(px, py);
}
}
ctx.strokeStyle = '#c9b8ff';
ctx.lineWidth = 2;
ctx.stroke();
var lx = cx + (sepHist.length - 1) / SIM_RESET_FRAMES * cwid;
disc(lx, cy + chei - logLevel(sepNow) * chei, 3, '#ffffff');
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpTwinDivergenceTinyOffset.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 출발 각도가 OFFSET 만 다른 두 진자를 같은 식으로 적분하고, 두 끝 추가 사이 거리를 로그 눈금으로 쌓는다
var DT = 0.01;
var SUBSTEPS = 4;
var START_T1 = 2.2;
var START_T2 = 2.6;
var OFFSET = 1e-6;
var SPLIT_DIST = 0.1;
var SIM_RESET_FRAMES = 420;
var TRAIL_LEN = 110;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 38, PIVOT_X = 112, PIVOT_Y = 78;
var PREROLL_FRAMES = 150;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var simA: number[] = [START_T1, START_T2, 0, 0];
var simB: number[] = [START_T1 + OFFSET, START_T2, 0, 0];
var trailA: Pt[] = [];
var trailB: Pt[] = [];
var sepHist: number[] = [];
var frameCount = 0;
var splitFrame = -1;
var sepNow = OFFSET;
function advance(): void {
for (var i = 0; i < SUBSTEPS; i += 1) { rk4Step(simA, DT); rk4Step(simB, DT); }
var pa = bobTwo(simA, PIVOT_X, PIVOT_Y), pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
pushTrail(trailA, pa.x, pa.y, TRAIL_LEN);
pushTrail(trailB, pb.x, pb.y, TRAIL_LEN);
sepNow = Math.hypot(pa.x - pb.x, pa.y - pb.y) / UNIT;
sepHist.push(sepNow);
frameCount += 1;
if (splitFrame < 0 && sepNow > SPLIT_DIST) {
splitFrame = frameCount;
setStatus('두 진자가 갈라졌다');
}
}
function restart(): void {
simA = [START_T1, START_T2, 0, 0];
simB = [START_T1 + OFFSET, START_T2, 0, 0];
trailA = [];
trailB = [];
sepHist = [];
frameCount = 0;
splitFrame = -1;
setStatus('시연 중');
for (var i = 0; i < PREROLL_FRAMES; i += 1) { advance(); }
}
function stepFrame(): void {
advance();
if (frameCount >= SIM_RESET_FRAMES) { restart(); }
}
function logLevel(d: number): number { return clamp((Math.log(Math.max(d, 1e-9)) / Math.LN10 + 8) / 8.4, 0, 1); }
function draw(): void {
var pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
var warm = splitFrame >= 0 ? clamp((frameCount - splitFrame) / 30, 0, 1) : 0;
beginDraw(warm > 0.5 ? '#5a4160' : '#463e68', warm > 0.5 ? '#2f2238' : '#2a2442', pb.x, pb.y);
glow(pb.x, pb.y, 90, '159,216,255', 0.24);
drawTrail(trailB, '159,216,255', 4, 0.85);
drawTrail(trailA, '255,179,199', 3, 0.85);
drawArms(simB, PIVOT_X, PIVOT_Y, 'rgba(159,216,255,0.85)', 'rgba(159,216,255,0.85)', 6);
drawArms(simA, PIVOT_X, PIVOT_Y, '#ffb3c7', '#ffb3c7', 2.5);
var pa = bobTwo(simA, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
glow(pb.x, pb.y, 22, '159,216,255', 0.7);
disc(pb.x, pb.y, 7, '#9fd8ff');
glow(pa.x, pa.y, 18, '255,179,199', 0.7);
disc(pa.x, pa.y, 5, '#ffb3c7');
if (splitFrame >= 0 && frameCount - splitFrame < 40) {
var f = (frameCount - splitFrame) / 40;
ctx.beginPath();
ctx.arc(pb.x, pb.y, 10 + f * 40, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,241,201,' + (0.9 * (1 - f)) + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
// 두 끝 추가 사이 거리 — 세로 로그 눈금
var cx = 196, cy = 12, cwid = 116, chei = 54;
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(cx, cy, cwid, chei);
ctx.strokeStyle = 'rgba(255,241,201,0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
var ty = cy + chei - logLevel(SPLIT_DIST) * chei;
ctx.beginPath();
ctx.moveTo(cx, ty);
ctx.lineTo(cx + cwid, ty);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
for (var i = 0; i < sepHist.length; i += 1) {
var px = cx + i / SIM_RESET_FRAMES * cwid;
var py = cy + chei - logLevel(sepHist[i]) * chei;
if (i === 0) { ctx.moveTo(px, py); } else { ctx.lineTo(px, py); }
}
ctx.strokeStyle = '#c9b8ff';
ctx.lineWidth = 2;
ctx.stroke();
var lx = cx + (sepHist.length - 1) / SIM_RESET_FRAMES * cwid;
disc(lx, cy + chei - logLevel(sepNow) * chei, 3, '#ffffff');
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpTwinDivergenceTinyOffset() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="출발 각도를 백만분의 일만 다르게 한 두 진자를 겹쳐 돌리고 끝 추가 사이 거리를 로그 눈금 그래프로 그려, 하나로 보이던 둘이 갈라서는 순간을 보인다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>출발 각도를 백만분의 일만 다르게 한 두 진자를 겹쳐 돌리고 끝 추가 사이 거리를 로그 눈금 그래프로 그려, 하나로 보이던 둘이 갈라서는 순간을 보인다.</p>
</div>
</main>
);
}03 · 머리카락 한 올 차이로 갈라서는 쌍둥이 진자 — 측정 누적면적 62.895% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function advance() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(simA, DT);
rk4Step(simB, DT);
}
var pa = bobTwo(simA, PIVOT_X, PIVOT_Y), pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
pushTrail(trailA, pa.x, pa.y, TRAIL_LEN);
pushTrail(trailB, pb.x, pb.y, TRAIL_LEN);
sepNow = Math.hypot(pa.x - pb.x, pa.y - pb.y) / UNIT;
sepHist.push(sepNow);
frameCount += 1;
if (splitFrame < 0 && sepNow > SPLIT_DIST) {
splitFrame = frameCount;
setStatus('두 진자가 갈라졌다');
}
}
function restart() {
simA = [START_T1, START_T2, 0, 0];
simB = [START_T1 + OFFSET, START_T2, 0, 0];
trailA = [];
trailB = [];
sepHist = [];
frameCount = 0;
splitFrame = -1;
setStatus('시연 중');
for (var i = 0; i < PREROLL_FRAMES; i += 1) {
advance();
}
}
04두 각도로 그리는 위상 평면 궤적
첫째 각도를 가로 planeX, 둘째 각도를 세로 planeY 에 놓고 걸음마다 직전 점과 이으며, wrapAngle 이 각도를 -π~π 로 감기 때문에 화면 끝을 건너뛰는 선분(차이가 PLANE_SIZE 의 절반 넘음)은 긋지 않는다. 눈에 안 보이는 오프스크린 캔버스에 매 프레임 FADE_ALPHA(0.012) 알파의 어두운 사각형을 덮어 오래된 선을 지우고, 선 색상은 경과 시간(밀리초)을 14 로 나눈 만큼 돌아간다. 면적은 이 편에서 가장 작은 32.4%이지만 평균 강도는 54.5 로 높고 23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="첫째 각도를 가로, 둘째 각도를 세로에 놓고 걸음마다 점을 이어 붙이며 오래된 선을 옅게 지워, 진자의 움직임이 한 장의 선 그림으로 쌓이게 한다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">첫째 각도를 가로, 둘째 각도를 세로에 놓고 걸음마다 점을 이어 붙이며 오래된 선을 옅게 지워, 진자의 움직임이 한 장의 선 그림으로 쌓이게 한다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 첫째 각도를 가로, 둘째 각도를 세로에 놓고 걸음마다 점을 이어 붙이며 FADE_ALPHA 로 오래된 선을 옅게 지운다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 1.9;
var START_T2 = 2.4;
var FADE_ALPHA = 0.012;
var PLANE_SIZE = 192;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 30, PIVOT_X = 82, PIVOT_Y = 72;
var PREROLL_FRAMES = 90;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var PI = Math.PI;
var plane = document.createElement('canvas');
plane.width = PLANE_SIZE;
plane.height = PLANE_SIZE;
var pg0 = plane.getContext('2d');
if (!pg0) {
return function () { return; };
}
var pg = pg0;
pg.fillStyle = '#1d1a2b';
pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
var sim = [START_T1, START_T2, 0, 0];
var armTrail = [];
var lastPx = 0, lastPy = 0, hasLast = false;
function wrapAngle(a) {
var r = (a + PI) % (2 * PI);
if (r < 0) {
r += 2 * PI;
}
return r - PI;
}
function planeX(a) { return (wrapAngle(a) + PI) / (2 * PI) * PLANE_SIZE; }
function planeY(a) { return (PI - wrapAngle(a)) / (2 * PI) * PLANE_SIZE; }
function stepFrame() {
pg.fillStyle = 'rgba(29,26,43,' + FADE_ALPHA + ')';
pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
pg.lineWidth = 2.4;
pg.lineCap = 'round';
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var px = planeX(sim[0]), py = planeY(sim[1]);
if (hasLast && Math.abs(px - lastPx) < PLANE_SIZE / 2 && Math.abs(py - lastPy) < PLANE_SIZE / 2) {
pg.beginPath();
pg.moveTo(lastPx, lastPy);
pg.lineTo(px, py);
pg.strokeStyle = hsl(250 + elapsed / 14, 80, 78, 0.95);
pg.stroke();
}
lastPx = px;
lastPy = py;
hasLast = true;
}
var q = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(armTrail, q.x, q.y, 50);
}
function draw() {
var b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#463e68', '#28223f', 230, 80);
var px0 = 176, py0 = 14, ps = 128;
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(px0 - 4, py0 - 4, ps + 8, ps + 8);
ctx.drawImage(plane, px0, py0, ps, ps);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
ctx.strokeRect(px0, py0, ps, ps);
ctx.beginPath();
ctx.moveTo(px0 + ps / 2, py0);
ctx.lineTo(px0 + ps / 2, py0 + ps);
ctx.moveTo(px0, py0 + ps / 2);
ctx.lineTo(px0 + ps, py0 + ps / 2);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.stroke();
var cx = px0 + planeX(sim[0]) / PLANE_SIZE * ps;
var cy = py0 + planeY(sim[1]) / PLANE_SIZE * ps;
ctx.setLineDash([2, 3]);
ctx.strokeStyle = 'rgba(255,241,201,0.7)';
ctx.beginPath();
ctx.moveTo(cx, py0 + ps);
ctx.lineTo(cx, cy);
ctx.lineTo(px0, cy);
ctx.stroke();
ctx.setLineDash([]);
glow(cx, cy, 14, '255,255,255', 0.8);
disc(cx, cy, 3, '#ffffff');
glow(b.x, b.y, 50, '201,184,255', 0.25);
drawTrail(armTrail, '255,179,199', 3, 0.8);
drawArms(sim, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 4);
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
disc(a.x, a.y, 6, '#c9b8ff');
disc(b.x, b.y, 7, '#ffb3c7');
// 두 각도를 팔 옆의 호로 — 위상 평면의 점과 같은 값
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, 16, Math.PI / 2 - sim[0], Math.PI / 2, sim[0] < 0);
ctx.strokeStyle = 'rgba(201,184,255,0.8)';
ctx.lineWidth = 2;
ctx.stroke();
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpPhaseSpaceAnglePlotTrace.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 첫째 각도를 가로, 둘째 각도를 세로에 놓고 걸음마다 점을 이어 붙이며 FADE_ALPHA 로 오래된 선을 옅게 지운다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 1.9;
var START_T2 = 2.4;
var FADE_ALPHA = 0.012;
var PLANE_SIZE = 192;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 30, PIVOT_X = 82, PIVOT_Y = 72;
var PREROLL_FRAMES = 90;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var PI = Math.PI;
var plane = document.createElement('canvas');
plane.width = PLANE_SIZE;
plane.height = PLANE_SIZE;
var pg0 = plane.getContext('2d');
if (!pg0) { return function (): void { return; }; }
var pg: CanvasRenderingContext2D = pg0;
pg.fillStyle = '#1d1a2b';
pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
var sim: number[] = [START_T1, START_T2, 0, 0];
var armTrail: Pt[] = [];
var lastPx = 0, lastPy = 0, hasLast = false;
function wrapAngle(a: number): number {
var r = (a + PI) % (2 * PI);
if (r < 0) { r += 2 * PI; }
return r - PI;
}
function planeX(a: number): number { return (wrapAngle(a) + PI) / (2 * PI) * PLANE_SIZE; }
function planeY(a: number): number { return (PI - wrapAngle(a)) / (2 * PI) * PLANE_SIZE; }
function stepFrame(): void {
pg.fillStyle = 'rgba(29,26,43,' + FADE_ALPHA + ')';
pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
pg.lineWidth = 2.4;
pg.lineCap = 'round';
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var px = planeX(sim[0]), py = planeY(sim[1]);
if (hasLast && Math.abs(px - lastPx) < PLANE_SIZE / 2 && Math.abs(py - lastPy) < PLANE_SIZE / 2) {
pg.beginPath();
pg.moveTo(lastPx, lastPy);
pg.lineTo(px, py);
pg.strokeStyle = hsl(250 + elapsed / 14, 80, 78, 0.95);
pg.stroke();
}
lastPx = px;
lastPy = py;
hasLast = true;
}
var q = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(armTrail, q.x, q.y, 50);
}
function draw(): void {
var b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#463e68', '#28223f', 230, 80);
var px0 = 176, py0 = 14, ps = 128;
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(px0 - 4, py0 - 4, ps + 8, ps + 8);
ctx.drawImage(plane, px0, py0, ps, ps);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
ctx.strokeRect(px0, py0, ps, ps);
ctx.beginPath();
ctx.moveTo(px0 + ps / 2, py0);
ctx.lineTo(px0 + ps / 2, py0 + ps);
ctx.moveTo(px0, py0 + ps / 2);
ctx.lineTo(px0 + ps, py0 + ps / 2);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.stroke();
var cx = px0 + planeX(sim[0]) / PLANE_SIZE * ps;
var cy = py0 + planeY(sim[1]) / PLANE_SIZE * ps;
ctx.setLineDash([2, 3]);
ctx.strokeStyle = 'rgba(255,241,201,0.7)';
ctx.beginPath();
ctx.moveTo(cx, py0 + ps);
ctx.lineTo(cx, cy);
ctx.lineTo(px0, cy);
ctx.stroke();
ctx.setLineDash([]);
glow(cx, cy, 14, '255,255,255', 0.8);
disc(cx, cy, 3, '#ffffff');
glow(b.x, b.y, 50, '201,184,255', 0.25);
drawTrail(armTrail, '255,179,199', 3, 0.8);
drawArms(sim, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 4);
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
disc(a.x, a.y, 6, '#c9b8ff');
disc(b.x, b.y, 7, '#ffb3c7');
// 두 각도를 팔 옆의 호로 — 위상 평면의 점과 같은 값
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, 16, Math.PI / 2 - sim[0], Math.PI / 2, sim[0] < 0);
ctx.strokeStyle = 'rgba(201,184,255,0.8)';
ctx.lineWidth = 2;
ctx.stroke();
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpPhaseSpaceAnglePlotTrace() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="첫째 각도를 가로, 둘째 각도를 세로에 놓고 걸음마다 점을 이어 붙이며 오래된 선을 옅게 지워, 진자의 움직임이 한 장의 선 그림으로 쌓이게 한다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>첫째 각도를 가로, 둘째 각도를 세로에 놓고 걸음마다 점을 이어 붙이며 오래된 선을 옅게 지워, 진자의 움직임이 한 장의 선 그림으로 쌓이게 한다.</p>
</div>
</main>
);
}04 · 두 각도로 그리는 위상 평면 궤적 — 측정 누적면적 32.436% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function wrapAngle(a) {
var r = (a + PI) % (2 * PI);
if (r < 0) {
r += 2 * PI;
}
return r - PI;
}
function planeX(a) { return (wrapAngle(a) + PI) / (2 * PI) * PLANE_SIZE; }
function planeY(a) { return (PI - wrapAngle(a)) / (2 * PI) * PLANE_SIZE; }
function stepFrame() {
pg.fillStyle = 'rgba(29,26,43,' + FADE_ALPHA + ')';
pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
pg.lineWidth = 2.4;
pg.lineCap = 'round';
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var px = planeX(sim[0]), py = planeY(sim[1]);
if (hasLast && Math.abs(px - lastPx) < PLANE_SIZE / 2 && Math.abs(py - lastPy) < PLANE_SIZE / 2) {
pg.beginPath();
pg.moveTo(lastPx, lastPy);
pg.lineTo(px, py);
pg.strokeStyle = hsl(250 + elapsed / 14, 80, 78, 0.95);
pg.stroke();
}
lastPx = px;
lastPy = py;
hasLast = true;
}
var q = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(armTrail, q.x, q.y, 50);
}
05계산법마다 에너지가 어긋나는 크기를 재는 비교 계기
같은 출발에서 rk4Step 과 eulerStep(현재 기울기로 한 번만 전진)을 나란히 돌려 totalEnergy 가 처음 값에서 몇 % 벗어났는지(drift)를 막대와 숫자로 보이는데, 같은 식으로 계산한 오일러는 1초 +20.4%·3초 +61%·5초 +98%·10초 +217% 로 불어나고 RK4 는 10초에도 ±1e-4% 안이라 화면에는 「-0.000%」 로 찍힌다. 오일러가 EULER_LIMIT_PCT 240 을 넘거나 SIM_RESET_FRAMES 330 프레임에 닿으면 restart() 로 되감는다. 누적면적 53.6%·평균 강도 39.4·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="같은 출발에서 오일러와 룽게-쿠타를 나란히 돌리고 처음 에너지와의 차이를 막대와 숫자로 보여, 계산법에 따라 진자가 점점 빨라지는 오류를 눈으로 잡는다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">같은 출발에서 오일러와 룽게-쿠타를 나란히 돌리고 처음 에너지와의 차이를 막대와 숫자로 보여, 계산법에 따라 진자가 점점 빨라지는 오류를 눈으로 잡는다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 같은 출발에서 오일러와 룽게-쿠타를 나란히 돌리고, 둘 다 같은 totalEnergy 식으로 처음 에너지와의 차이를 잰다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.0;
var START_T2 = 1.6;
var EULER_LIMIT_PCT = 240;
var BAR_FULL_PCT = 120;
var SIM_RESET_FRAMES = 330;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 30, PIVOT_L = 80, PIVOT_R = 240, PIVOT_Y = 90;
var PREROLL_FRAMES = 60;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
function eulerStep(s, h) {
var a1 = accTheta1(s[0], s[1], s[2], s[3]);
var a2 = accTheta2(s[0], s[1], s[2], s[3]);
s[0] += h * s[2];
s[1] += h * s[3];
s[2] += h * a1;
s[3] += h * a2;
}
// 전체 에너지 = 운동 + 위치 (아래 방향이 기준 0 이 되도록 높이를 잰다)
function totalEnergy(s) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var kinetic = 0.5 * (M1 + M2) * L1 * L1 * w1 * w1 + 0.5 * M2 * L2 * L2 * w2 * w2
+ M2 * L1 * L2 * w1 * w2 * Math.cos(t1 - t2);
var potential = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2);
return kinetic + potential;
}
var simR = [START_T1, START_T2, 0, 0];
var simE = [START_T1, START_T2, 0, 0];
var energyZero = totalEnergy(simR);
var driftR = 0, driftE = 0;
var frameCount = 0;
var trailR = [];
var trailE = [];
function drift(s) {
var d = (totalEnergy(s) - energyZero) / Math.abs(energyZero) * 100;
return isFinite(d) ? d : 9999;
}
function restart() {
simR = [START_T1, START_T2, 0, 0];
simE = [START_T1, START_T2, 0, 0];
trailR = [];
trailE = [];
frameCount = 0;
}
function stepFrame() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(simR, DT);
eulerStep(simE, DT);
}
driftR = drift(simR);
driftE = drift(simE);
var pr = bobTwo(simR, PIVOT_L, PIVOT_Y), pe = bobTwo(simE, PIVOT_R, PIVOT_Y);
pushTrail(trailR, pr.x, pr.y, 70);
pushTrail(trailE, pe.x, pe.y, 70);
frameCount += 1;
if (Math.abs(driftE) > EULER_LIMIT_PCT || frameCount >= SIM_RESET_FRAMES) {
restart();
}
}
function bar(y, pct, rgb, label) {
var x0 = 92, wid = 136;
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(x0, y, wid, 9);
ctx.fillStyle = 'rgba(' + rgb + ',0.95)';
ctx.fillRect(x0, y, clamp(Math.abs(pct) / BAR_FULL_PCT, 0, 1) * wid, 9);
ctx.font = '8px monospace';
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'middle';
ctx.fillText(label + ' ' + (pct >= 0 ? '+' : '-') + Math.abs(pct).toFixed(3) + '%', x0 + 4, y + 4.5);
}
function draw() {
var pr = bobTwo(simR, PIVOT_L, PIVOT_Y), pe = bobTwo(simE, PIVOT_R, PIVOT_Y);
var heat = clamp(Math.abs(driftE) / EULER_LIMIT_PCT, 0, 1);
beginDraw(heat > 0.5 ? '#54405f' : '#463e68', '#28223f', 160, 100);
ctx.fillStyle = 'rgba(168,230,207,0.05)';
ctx.fillRect(0, 0, 160, CALC_H);
ctx.fillStyle = 'rgba(255,159,143,' + (0.04 + 0.16 * heat) + ')';
ctx.fillRect(160, 0, 160, CALC_H);
glow(pr.x, pr.y, 60, '168,230,207', 0.3);
glow(pe.x, pe.y, 60 + 30 * heat, '255,159,143', 0.3 + 0.3 * heat);
drawTrail(trailR, '168,230,207', 3.5, 0.8);
drawTrail(trailE, '255,159,143', 3.5, 0.8);
drawArms(simR, PIVOT_L, PIVOT_Y, '#a8e6cf', '#c9f1e0', 4);
drawArms(simE, PIVOT_R, PIVOT_Y, '#ff9f8f', '#ffd6c8', 4);
var ar = bobOne(simR, PIVOT_L, PIVOT_Y), ae = bobOne(simE, PIVOT_R, PIVOT_Y);
disc(PIVOT_L, PIVOT_Y, 4, '#fff1c9');
disc(PIVOT_R, PIVOT_Y, 4, '#fff1c9');
disc(ar.x, ar.y, 6, '#a8e6cf');
disc(ae.x, ae.y, 6, '#ff9f8f');
glow(pr.x, pr.y, 18, '168,230,207', 0.7);
disc(pr.x, pr.y, 7, '#c9f1e0');
glow(pe.x, pe.y, 18, '255,159,143', 0.7);
disc(pe.x, pe.y, 7, '#ffd6c8');
bar(30, driftR, '168,230,207', 'RK4');
bar(44, driftE, '255,159,143', 'Euler');
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpEnergyDriftMeterEulerVsRk4.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 같은 출발에서 오일러와 룽게-쿠타를 나란히 돌리고, 둘 다 같은 totalEnergy 식으로 처음 에너지와의 차이를 잰다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.0;
var START_T2 = 1.6;
var EULER_LIMIT_PCT = 240;
var BAR_FULL_PCT = 120;
var SIM_RESET_FRAMES = 330;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 30, PIVOT_L = 80, PIVOT_R = 240, PIVOT_Y = 90;
var PREROLL_FRAMES = 60;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
function eulerStep(s: number[], h: number): void {
var a1 = accTheta1(s[0], s[1], s[2], s[3]);
var a2 = accTheta2(s[0], s[1], s[2], s[3]);
s[0] += h * s[2];
s[1] += h * s[3];
s[2] += h * a1;
s[3] += h * a2;
}
// 전체 에너지 = 운동 + 위치 (아래 방향이 기준 0 이 되도록 높이를 잰다)
function totalEnergy(s: number[]): number {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var kinetic = 0.5 * (M1 + M2) * L1 * L1 * w1 * w1 + 0.5 * M2 * L2 * L2 * w2 * w2
+ M2 * L1 * L2 * w1 * w2 * Math.cos(t1 - t2);
var potential = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2);
return kinetic + potential;
}
var simR: number[] = [START_T1, START_T2, 0, 0];
var simE: number[] = [START_T1, START_T2, 0, 0];
var energyZero = totalEnergy(simR);
var driftR = 0, driftE = 0;
var frameCount = 0;
var trailR: Pt[] = [];
var trailE: Pt[] = [];
function drift(s: number[]): number {
var d = (totalEnergy(s) - energyZero) / Math.abs(energyZero) * 100;
return isFinite(d) ? d : 9999;
}
function restart(): void {
simR = [START_T1, START_T2, 0, 0];
simE = [START_T1, START_T2, 0, 0];
trailR = [];
trailE = [];
frameCount = 0;
}
function stepFrame(): void {
for (var i = 0; i < SUBSTEPS; i += 1) { rk4Step(simR, DT); eulerStep(simE, DT); }
driftR = drift(simR);
driftE = drift(simE);
var pr = bobTwo(simR, PIVOT_L, PIVOT_Y), pe = bobTwo(simE, PIVOT_R, PIVOT_Y);
pushTrail(trailR, pr.x, pr.y, 70);
pushTrail(trailE, pe.x, pe.y, 70);
frameCount += 1;
if (Math.abs(driftE) > EULER_LIMIT_PCT || frameCount >= SIM_RESET_FRAMES) { restart(); }
}
function bar(y: number, pct: number, rgb: string, label: string): void {
var x0 = 92, wid = 136;
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(x0, y, wid, 9);
ctx.fillStyle = 'rgba(' + rgb + ',0.95)';
ctx.fillRect(x0, y, clamp(Math.abs(pct) / BAR_FULL_PCT, 0, 1) * wid, 9);
ctx.font = '8px monospace';
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'middle';
ctx.fillText(label + ' ' + (pct >= 0 ? '+' : '-') + Math.abs(pct).toFixed(3) + '%', x0 + 4, y + 4.5);
}
function draw(): void {
var pr = bobTwo(simR, PIVOT_L, PIVOT_Y), pe = bobTwo(simE, PIVOT_R, PIVOT_Y);
var heat = clamp(Math.abs(driftE) / EULER_LIMIT_PCT, 0, 1);
beginDraw(heat > 0.5 ? '#54405f' : '#463e68', '#28223f', 160, 100);
ctx.fillStyle = 'rgba(168,230,207,0.05)';
ctx.fillRect(0, 0, 160, CALC_H);
ctx.fillStyle = 'rgba(255,159,143,' + (0.04 + 0.16 * heat) + ')';
ctx.fillRect(160, 0, 160, CALC_H);
glow(pr.x, pr.y, 60, '168,230,207', 0.3);
glow(pe.x, pe.y, 60 + 30 * heat, '255,159,143', 0.3 + 0.3 * heat);
drawTrail(trailR, '168,230,207', 3.5, 0.8);
drawTrail(trailE, '255,159,143', 3.5, 0.8);
drawArms(simR, PIVOT_L, PIVOT_Y, '#a8e6cf', '#c9f1e0', 4);
drawArms(simE, PIVOT_R, PIVOT_Y, '#ff9f8f', '#ffd6c8', 4);
var ar = bobOne(simR, PIVOT_L, PIVOT_Y), ae = bobOne(simE, PIVOT_R, PIVOT_Y);
disc(PIVOT_L, PIVOT_Y, 4, '#fff1c9');
disc(PIVOT_R, PIVOT_Y, 4, '#fff1c9');
disc(ar.x, ar.y, 6, '#a8e6cf');
disc(ae.x, ae.y, 6, '#ff9f8f');
glow(pr.x, pr.y, 18, '168,230,207', 0.7);
disc(pr.x, pr.y, 7, '#c9f1e0');
glow(pe.x, pe.y, 18, '255,159,143', 0.7);
disc(pe.x, pe.y, 7, '#ffd6c8');
bar(30, driftR, '168,230,207', 'RK4');
bar(44, driftE, '255,159,143', 'Euler');
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpEnergyDriftMeterEulerVsRk4() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="같은 출발에서 오일러와 룽게-쿠타를 나란히 돌리고 처음 에너지와의 차이를 막대와 숫자로 보여, 계산법에 따라 진자가 점점 빨라지는 오류를 눈으로 잡는다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>같은 출발에서 오일러와 룽게-쿠타를 나란히 돌리고 처음 에너지와의 차이를 막대와 숫자로 보여, 계산법에 따라 진자가 점점 빨라지는 오류를 눈으로 잡는다.</p>
</div>
</main>
);
}05 · 계산법마다 에너지가 어긋나는 크기를 재는 비교 계기 — 측정 누적면적 53.567% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function eulerStep(s, h) {
var a1 = accTheta1(s[0], s[1], s[2], s[3]);
var a2 = accTheta2(s[0], s[1], s[2], s[3]);
s[0] += h * s[2];
s[1] += h * s[3];
s[2] += h * a1;
s[3] += h * a2;
}
// 전체 에너지 = 운동 + 위치 (아래 방향이 기준 0 이 되도록 높이를 잰다)
function totalEnergy(s) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var kinetic = 0.5 * (M1 + M2) * L1 * L1 * w1 * w1 + 0.5 * M2 * L2 * L2 * w2 * w2
+ M2 * L1 * L2 * w1 * w2 * Math.cos(t1 - t2);
var potential = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2);
return kinetic + potential;
}
06열여섯 진자가 부채꼴로 퍼지는 색 띠
COUNT 16 개의 진자가 첫째 각도만 OFFSET_STEP 0.004 씩 벌어져(마지막은 처음보다 0.06 라디안 더 기울어) 출발하고, hueOf(i) 가 번호에 비례해 색상을 330 도부터 HUE_SPAN 300 도만큼 돌려 한 점에서 출발한 색 띠가 흩어지게 한다. 진자마다 끝 추가 궤적 TRAIL_LEN 26 점이 따라붙고 SIM_RESET_FRAMES 340 프레임에서 다시 시작한다. 누적면적 62.5%·평균 강도 38.6·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="출발 각도를 번호에 비례해 조금씩 벌린 열여섯 진자를 색상 순서대로 겹쳐 그려, 한 점에서 출발한 무지개가 어느 순간 흩어지며 엉키는 장면을 보인다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">출발 각도를 번호에 비례해 조금씩 벌린 열여섯 진자를 색상 순서대로 겹쳐 그려, 한 점에서 출발한 무지개가 어느 순간 흩어지며 엉키는 장면을 보인다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 출발 각도를 OFFSET_STEP 에 번호를 곱한 식으로만 벌려 열여섯 진자를 한 식으로 적분하고, 번호를 색상에 그대로 대응시킨다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.4;
var START_T2 = 2.2;
var COUNT = 16;
var OFFSET_STEP = 0.004;
var HUE_SPAN = 300;
var SIM_RESET_FRAMES = 340;
var TRAIL_LEN = 26;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 40, PIVOT_X = 160, PIVOT_Y = 74;
var PREROLL_FRAMES = 40;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var fan = [];
var trails = [];
var frameCount = 0;
function restart() {
fan = [];
trails = [];
for (var i = 0; i < COUNT; i += 1) {
fan.push([START_T1 + OFFSET_STEP * i, START_T2, 0, 0]);
trails.push([]);
}
frameCount = 0;
}
restart();
function stepFrame() {
for (var i = 0; i < COUNT; i += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) {
rk4Step(fan[i], DT);
}
var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
pushTrail(trails[i], p.x, p.y, TRAIL_LEN);
}
frameCount += 1;
if (frameCount >= SIM_RESET_FRAMES) {
restart();
}
}
function hueOf(i) { return 330 + HUE_SPAN * i / (COUNT - 1); }
function draw() {
var lead = bobTwo(fan[COUNT >> 1], PIVOT_X, PIVOT_Y);
beginDraw('#463e68', '#28223f', lead.x, lead.y);
glow(lead.x, lead.y, 100, '255,238,252', 0.22);
for (var i = 0; i < COUNT; i += 1) {
var col = hsl(hueOf(i), 85, 80, 1);
var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
drawTrail(trails[i], '255,255,255', 2.2, 0.25);
ctx.lineCap = 'round';
for (var j = 1; j < trails[i].length; j += 1) {
var f = j / trails[i].length;
ctx.beginPath();
ctx.moveTo(trails[i][j - 1].x, trails[i][j - 1].y);
ctx.lineTo(trails[i][j].x, trails[i][j].y);
ctx.strokeStyle = hsl(hueOf(i), 85, 80, 0.6 * f);
ctx.lineWidth = 1 + 1.6 * f;
ctx.stroke();
}
drawArms(fan[i], PIVOT_X, PIVOT_Y, hsl(hueOf(i), 80, 82, 0.5), hsl(hueOf(i), 80, 82, 0.7), 1.4);
glow(p.x, p.y, 10, '255,255,255', 0.35);
disc(p.x, p.y, 3.2, col);
}
disc(PIVOT_X, PIVOT_Y, 4.5, '#fff1c9');
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpSixteenFanEnsembleHueSpread.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 출발 각도를 OFFSET_STEP 에 번호를 곱한 식으로만 벌려 열여섯 진자를 한 식으로 적분하고, 번호를 색상에 그대로 대응시킨다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.4;
var START_T2 = 2.2;
var COUNT = 16;
var OFFSET_STEP = 0.004;
var HUE_SPAN = 300;
var SIM_RESET_FRAMES = 340;
var TRAIL_LEN = 26;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 40, PIVOT_X = 160, PIVOT_Y = 74;
var PREROLL_FRAMES = 40;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var fan: number[][] = [];
var trails: Pt[][] = [];
var frameCount = 0;
function restart(): void {
fan = [];
trails = [];
for (var i = 0; i < COUNT; i += 1) {
fan.push([START_T1 + OFFSET_STEP * i, START_T2, 0, 0]);
trails.push([]);
}
frameCount = 0;
}
restart();
function stepFrame(): void {
for (var i = 0; i < COUNT; i += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) { rk4Step(fan[i], DT); }
var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
pushTrail(trails[i], p.x, p.y, TRAIL_LEN);
}
frameCount += 1;
if (frameCount >= SIM_RESET_FRAMES) { restart(); }
}
function hueOf(i: number): number { return 330 + HUE_SPAN * i / (COUNT - 1); }
function draw(): void {
var lead = bobTwo(fan[COUNT >> 1], PIVOT_X, PIVOT_Y);
beginDraw('#463e68', '#28223f', lead.x, lead.y);
glow(lead.x, lead.y, 100, '255,238,252', 0.22);
for (var i = 0; i < COUNT; i += 1) {
var col = hsl(hueOf(i), 85, 80, 1);
var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
drawTrail(trails[i], '255,255,255', 2.2, 0.25);
ctx.lineCap = 'round';
for (var j = 1; j < trails[i].length; j += 1) {
var f = j / trails[i].length;
ctx.beginPath();
ctx.moveTo(trails[i][j - 1].x, trails[i][j - 1].y);
ctx.lineTo(trails[i][j].x, trails[i][j].y);
ctx.strokeStyle = hsl(hueOf(i), 85, 80, 0.6 * f);
ctx.lineWidth = 1 + 1.6 * f;
ctx.stroke();
}
drawArms(fan[i], PIVOT_X, PIVOT_Y, hsl(hueOf(i), 80, 82, 0.5), hsl(hueOf(i), 80, 82, 0.7), 1.4);
glow(p.x, p.y, 10, '255,255,255', 0.35);
disc(p.x, p.y, 3.2, col);
}
disc(PIVOT_X, PIVOT_Y, 4.5, '#fff1c9');
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpSixteenFanEnsembleHueSpread() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="출발 각도를 번호에 비례해 조금씩 벌린 열여섯 진자를 색상 순서대로 겹쳐 그려, 한 점에서 출발한 무지개가 어느 순간 흩어지며 엉키는 장면을 보인다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>출발 각도를 번호에 비례해 조금씩 벌린 열여섯 진자를 색상 순서대로 겹쳐 그려, 한 점에서 출발한 무지개가 어느 순간 흩어지며 엉키는 장면을 보인다.</p>
</div>
</main>
);
}06 · 열여섯 진자가 부채꼴로 퍼지는 색 띠 — 측정 누적면적 62.547% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function restart() {
fan = [];
trails = [];
for (var i = 0; i < COUNT; i += 1) {
fan.push([START_T1 + OFFSET_STEP * i, START_T2, 0, 0]);
trails.push([]);
}
frameCount = 0;
}
restart();
function stepFrame() {
for (var i = 0; i < COUNT; i += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) {
rk4Step(fan[i], DT);
}
var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
pushTrail(trails[i], p.x, p.y, TRAIL_LEN);
}
frameCount += 1;
if (frameCount >= SIM_RESET_FRAMES) {
restart();
}
}
function hueOf(i) { return 330 + HUE_SPAN * i / (COUNT - 1); }
07팔을 끌어다 놓아 출발 각도를 정하는 진자
pointerdown 이 가까운 팔 끝을 잡으면(GRAB_RADIUS 22, 최대 1.6배 거리까지 허용) held 가 켜져 stepFrame() 이 적분을 건너뛰고, dragTo 는 포인터 위치에서 Math.atan2 로 각도를 구해 속도를 0 으로 만든 뒤 놓는 순간 그 각도에서 다시 출발시키며, 키보드로는 ←→ 가 첫째 각도, ↑↓ 가 둘째 각도를 KEY_ANGLE_STEP 0.12 씩 바꾸고 Space 와 Enter 가 놓기와 잡기를 오간다. 아무도 만지지 않으면 DEMO_CYCLE_MS 4800 마다 DEMO_POSES 네 자세 중 하나를 가짜 포인터로 끌어 놓고 사람이 입력하면 시연을 멈춘다. 누적면적 61.8%·평균 강도 43.6·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="포인터나 방향키로 두 팔을 원하는 각도까지 끌어 놓으면 계산이 잠시 멈추고, 놓는 순간 그 각도에서 속도 0으로 다시 출발한다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">처음 자세</button>
<p class="bp__caption">포인터나 방향키로 두 팔을 원하는 각도까지 끌어 놓으면 계산이 잠시 멈추고, 놓는 순간 그 각도에서 속도 0으로 다시 출발한다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 포인터 위치에서 각도를 atan2 로 거꾸로 구해 팔 각도에 넣고, 끄는 동안은 적분을 멈췄다가 놓는 순간 속도 0 에서 출발시킨다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 1.2;
var START_T2 = 2.0;
var GRAB_RADIUS = 22;
var KEY_ANGLE_STEP = 0.12;
var PATH_STEPS = 150;
var TRAIL_LEN = 80;
var DEMO_CYCLE_MS = 4800;
var DEMO_DRAG_START_MS = 200;
var DEMO_DRAG_END_MS = 1300;
var DEMO_POSES = [[1.6, 2.4], [-2.0, 1.1], [2.5, -1.5], [-1.1, -2.4]];
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 34, PIVOT_X = 160, PIVOT_Y = 68;
var PREROLL_FRAMES = 0;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
ownInput = true;
var sim = [START_T1, START_T2, 0, 0];
var held = reduce;
var grab = 0;
var ringArm = 1;
var trail = [];
var preview = [];
var demoFrom = [START_T1, START_T2];
var demoCycle = -1;
var demoDragging = false;
var cursor = { x: PIVOT_X, y: PIVOT_Y };
function makePreview() {
var s = [sim[0], sim[1], 0, 0];
preview = [];
for (var i = 0; i < PATH_STEPS; i += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) {
rk4Step(s, DT);
}
var p = bobTwo(s, PIVOT_X, PIVOT_Y);
preview.push({ x: p.x, y: p.y });
}
}
function easeInOut(u) {
var v = clamp(u, 0, 1);
return v * v * (3 - 2 * v);
}
function setPose(t1, t2) {
sim[0] = t1;
sim[1] = t2;
sim[2] = 0;
sim[3] = 0;
trail = [];
}
function releaseArm() {
held = false;
grab = 0;
if (reduce) {
makePreview();
setStatus('출발 뒤 끝 추가가 갈 길을 그렸다');
}
else {
setStatus('놓았다 — 이 각도에서 출발');
}
}
function stepDemo() {
var cyc = Math.floor(elapsed / DEMO_CYCLE_MS);
var local = elapsed - cyc * DEMO_CYCLE_MS;
if (cyc !== demoCycle) {
demoCycle = cyc;
demoFrom = [sim[0], sim[1]];
demoDragging = false;
}
var pose = DEMO_POSES[cyc % DEMO_POSES.length];
if (local >= DEMO_DRAG_START_MS && local < DEMO_DRAG_END_MS) {
if (!demoDragging) {
demoDragging = true;
held = true;
demoFrom = [sim[0], sim[1]];
setStatus('끌어서 각도를 정하는 중');
}
var mid = DEMO_DRAG_START_MS + (DEMO_DRAG_END_MS - DEMO_DRAG_START_MS) * 0.5;
var u1 = easeInOut((local - DEMO_DRAG_START_MS) / (mid - DEMO_DRAG_START_MS));
var u2 = easeInOut((local - mid) / (DEMO_DRAG_END_MS - mid));
setPose(demoFrom[0] + (pose[0] - demoFrom[0]) * u1, demoFrom[1] + (pose[1] - demoFrom[1]) * u2);
ringArm = local < mid ? 1 : 2;
var target = local < mid ? bobOne(sim, PIVOT_X, PIVOT_Y) : bobTwo(sim, PIVOT_X, PIVOT_Y);
cursor = { x: target.x + 5, y: target.y + 5 };
}
else if (demoDragging && local >= DEMO_DRAG_END_MS) {
demoDragging = false;
held = false;
setStatus('놓았다 — 이 각도에서 출발');
}
}
function stepFrame() {
if (demo) {
stepDemo();
}
if (held) {
return;
}
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
}
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
function dragTo(x, y) {
if (grab === 1) {
sim[0] = Math.atan2(x - PIVOT_X, y - PIVOT_Y);
}
else if (grab === 2) {
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
sim[1] = Math.atan2(x - a.x, y - a.y);
}
sim[2] = 0;
sim[3] = 0;
trail = [];
if (reduce) {
makePreview();
}
}
listen(canvas, 'pointerdown', function (e) {
var pt = canvasPoint(e);
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
var da = Math.hypot(pt.x - a.x, pt.y - a.y), db = Math.hypot(pt.x - b.x, pt.y - b.y);
if (Math.min(da, db) > GRAB_RADIUS * 1.6) {
return;
}
stopDemo();
grab = db <= da ? 2 : 1;
ringArm = grab;
held = true;
canvas.setPointerCapture(e.pointerId);
setStatus('끌어서 각도를 정하는 중');
dragTo(pt.x, pt.y);
draw();
});
listen(canvas, 'pointermove', function (e) {
if (grab === 0) {
return;
}
var pt = canvasPoint(e);
dragTo(pt.x, pt.y);
draw();
});
listen(canvas, 'pointerup', function () {
if (grab === 0) {
return;
}
releaseArm();
draw();
});
listen(canvas, 'keydown', function (e) {
var k = e.key;
var moved = false;
if (k === 'ArrowLeft') {
sim[0] -= KEY_ANGLE_STEP;
moved = true;
ringArm = 1;
}
if (k === 'ArrowRight') {
sim[0] += KEY_ANGLE_STEP;
moved = true;
ringArm = 1;
}
if (k === 'ArrowUp') {
sim[1] += KEY_ANGLE_STEP;
moved = true;
ringArm = 2;
}
if (k === 'ArrowDown') {
sim[1] -= KEY_ANGLE_STEP;
moved = true;
ringArm = 2;
}
if (moved) {
e.preventDefault();
stopDemo();
held = true;
sim[2] = 0;
sim[3] = 0;
trail = [];
if (reduce) {
makePreview();
}
setStatus('방향키로 각도 조정 — 스페이스로 놓기');
draw();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
stopDemo();
if (held) {
releaseArm();
}
else {
held = true;
sim[2] = 0;
sim[3] = 0;
setStatus('방향키로 각도 조정 — 스페이스로 놓기');
}
draw();
}
});
onButton = function () {
stopDemo();
setPose(START_T1, START_T2);
held = true;
releaseArm();
if (reduce) {
held = true;
}
draw();
};
if (reduce) {
makePreview();
setStatus('끌어서 각도를 정하는 중');
}
function arc(cx, cy, r, from, to, rgb) {
ctx.beginPath();
ctx.arc(cx, cy, r, from, to, to < from);
ctx.strokeStyle = 'rgba(' + rgb + ',0.85)';
ctx.lineWidth = 2;
ctx.stroke();
}
function draw() {
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw(held ? '#4d4672' : '#463e68', '#28223f', b.x, b.y);
glow(b.x, b.y, 90, '255,238,252', held ? 0.34 : 0.22);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, UNIT * (L1 + L2), 0, 6.2832);
ctx.stroke();
ctx.setLineDash([]);
if (reduce && preview.length > 0) {
for (var i = 0; i < preview.length; i += 3) {
disc(preview[i].x, preview[i].y, 1.6, 'rgba(255,179,199,0.8)');
}
}
drawTrail(trail, '255,179,199', 3.5, 0.8);
ctx.setLineDash(held ? [4, 3] : []);
drawArms(sim, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 4.5);
ctx.setLineDash([]);
if (held) {
arc(PIVOT_X, PIVOT_Y, 20, Math.PI / 2, Math.PI / 2 - sim[0], '255,241,201');
arc(a.x, a.y, 16, Math.PI / 2, Math.PI / 2 - sim[1], '159,216,255');
seg(PIVOT_X, PIVOT_Y, PIVOT_X, PIVOT_Y + 28, 1, 'rgba(255,255,255,0.4)');
seg(a.x, a.y, a.x, a.y + 24, 1, 'rgba(255,255,255,0.4)');
}
disc(PIVOT_X, PIVOT_Y, 5, '#fff1c9');
glow(a.x, a.y, 20, '201,184,255', 0.5);
disc(a.x, a.y, 8, '#c9b8ff');
glow(b.x, b.y, 24, '255,179,199', 0.6);
disc(b.x, b.y, 10, '#ffb3c7');
if (held) {
ctx.beginPath();
ctx.arc(ringArm === 1 ? a.x : b.x, ringArm === 1 ? a.y : b.y, 15, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (demoDragging) {
ctx.beginPath();
ctx.moveTo(cursor.x, cursor.y);
ctx.lineTo(cursor.x + 9, cursor.y + 4);
ctx.lineTo(cursor.x + 4, cursor.y + 9);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.fill();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpDragArmSetReleaseAngles.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 포인터 위치에서 각도를 atan2 로 거꾸로 구해 팔 각도에 넣고, 끄는 동안은 적분을 멈췄다가 놓는 순간 속도 0 에서 출발시킨다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 1.2;
var START_T2 = 2.0;
var GRAB_RADIUS = 22;
var KEY_ANGLE_STEP = 0.12;
var PATH_STEPS = 150;
var TRAIL_LEN = 80;
var DEMO_CYCLE_MS = 4800;
var DEMO_DRAG_START_MS = 200;
var DEMO_DRAG_END_MS = 1300;
var DEMO_POSES: number[][] = [[1.6, 2.4], [-2.0, 1.1], [2.5, -1.5], [-1.1, -2.4]];
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 34, PIVOT_X = 160, PIVOT_Y = 68;
var PREROLL_FRAMES = 0;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
ownInput = true;
var sim: number[] = [START_T1, START_T2, 0, 0];
var held = reduce;
var grab = 0;
var ringArm = 1;
var trail: Pt[] = [];
var preview: Pt[] = [];
var demoFrom: number[] = [START_T1, START_T2];
var demoCycle = -1;
var demoDragging = false;
var cursor: Pt = { x: PIVOT_X, y: PIVOT_Y };
function makePreview(): void {
var s = [sim[0], sim[1], 0, 0];
preview = [];
for (var i = 0; i < PATH_STEPS; i += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) { rk4Step(s, DT); }
var p = bobTwo(s, PIVOT_X, PIVOT_Y);
preview.push({ x: p.x, y: p.y });
}
}
function easeInOut(u: number): number {
var v = clamp(u, 0, 1);
return v * v * (3 - 2 * v);
}
function setPose(t1: number, t2: number): void {
sim[0] = t1;
sim[1] = t2;
sim[2] = 0;
sim[3] = 0;
trail = [];
}
function releaseArm(): void {
held = false;
grab = 0;
if (reduce) {
makePreview();
setStatus('출발 뒤 끝 추가가 갈 길을 그렸다');
} else {
setStatus('놓았다 — 이 각도에서 출발');
}
}
function stepDemo(): void {
var cyc = Math.floor(elapsed / DEMO_CYCLE_MS);
var local = elapsed - cyc * DEMO_CYCLE_MS;
if (cyc !== demoCycle) {
demoCycle = cyc;
demoFrom = [sim[0], sim[1]];
demoDragging = false;
}
var pose = DEMO_POSES[cyc % DEMO_POSES.length];
if (local >= DEMO_DRAG_START_MS && local < DEMO_DRAG_END_MS) {
if (!demoDragging) { demoDragging = true; held = true; demoFrom = [sim[0], sim[1]]; setStatus('끌어서 각도를 정하는 중'); }
var mid = DEMO_DRAG_START_MS + (DEMO_DRAG_END_MS - DEMO_DRAG_START_MS) * 0.5;
var u1 = easeInOut((local - DEMO_DRAG_START_MS) / (mid - DEMO_DRAG_START_MS));
var u2 = easeInOut((local - mid) / (DEMO_DRAG_END_MS - mid));
setPose(demoFrom[0] + (pose[0] - demoFrom[0]) * u1, demoFrom[1] + (pose[1] - demoFrom[1]) * u2);
ringArm = local < mid ? 1 : 2;
var target = local < mid ?bobOne(sim, PIVOT_X, PIVOT_Y) : bobTwo(sim, PIVOT_X, PIVOT_Y);
cursor = { x: target.x + 5, y: target.y + 5 };
} else if (demoDragging && local >= DEMO_DRAG_END_MS) {
demoDragging = false;
held = false;
setStatus('놓았다 — 이 각도에서 출발');
}
}
function stepFrame(): void {
if (demo) { stepDemo(); }
if (held) { return; }
for (var i = 0; i < SUBSTEPS; i += 1) { rk4Step(sim, DT); }
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
function dragTo(x: number, y: number): void {
if (grab === 1) {
sim[0] = Math.atan2(x - PIVOT_X, y - PIVOT_Y);
} else if (grab === 2) {
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
sim[1] = Math.atan2(x - a.x, y - a.y);
}
sim[2] = 0;
sim[3] = 0;
trail = [];
if (reduce) { makePreview(); }
}
listen(canvas, 'pointerdown', function (e: Event): void {
var pt = canvasPoint(e as PointerEvent);
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
var da = Math.hypot(pt.x - a.x, pt.y - a.y), db = Math.hypot(pt.x - b.x, pt.y - b.y);
if (Math.min(da, db) > GRAB_RADIUS * 1.6) { return; }
stopDemo();
grab = db <= da ? 2 : 1;
ringArm = grab;
held = true;
canvas.setPointerCapture((e as PointerEvent).pointerId);
setStatus('끌어서 각도를 정하는 중');
dragTo(pt.x, pt.y);
draw();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (grab === 0) { return; }
var pt = canvasPoint(e as PointerEvent);
dragTo(pt.x, pt.y);
draw();
});
listen(canvas, 'pointerup', function (): void {
if (grab === 0) { return; }
releaseArm();
draw();
});
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
var moved = false;
if (k === 'ArrowLeft') { sim[0] -= KEY_ANGLE_STEP; moved = true; ringArm = 1; }
if (k === 'ArrowRight') { sim[0] += KEY_ANGLE_STEP; moved = true; ringArm = 1; }
if (k === 'ArrowUp') { sim[1] += KEY_ANGLE_STEP; moved = true; ringArm = 2; }
if (k === 'ArrowDown') { sim[1] -= KEY_ANGLE_STEP; moved = true; ringArm = 2; }
if (moved) {
e.preventDefault();
stopDemo();
held = true;
sim[2] = 0;
sim[3] = 0;
trail = [];
if (reduce) { makePreview(); }
setStatus('방향키로 각도 조정 — 스페이스로 놓기');
draw();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
stopDemo();
if (held) { releaseArm(); } else { held = true; sim[2] = 0; sim[3] = 0; setStatus('방향키로 각도 조정 — 스페이스로 놓기'); }
draw();
}
});
onButton = function (): void {
stopDemo();
setPose(START_T1, START_T2);
held = true;
releaseArm();
if (reduce) { held = true; }
draw();
};
if (reduce) { makePreview(); setStatus('끌어서 각도를 정하는 중'); }
function arc(cx: number, cy: number, r: number, from: number, to: number, rgb: string): void {
ctx.beginPath();
ctx.arc(cx, cy, r, from, to, to < from);
ctx.strokeStyle = 'rgba(' + rgb + ',0.85)';
ctx.lineWidth = 2;
ctx.stroke();
}
function draw(): void {
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw(held ? '#4d4672' : '#463e68', '#28223f', b.x, b.y);
glow(b.x, b.y, 90, '255,238,252', held ? 0.34 : 0.22);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.arc(PIVOT_X, PIVOT_Y, UNIT * (L1 + L2), 0, 6.2832);
ctx.stroke();
ctx.setLineDash([]);
if (reduce && preview.length > 0) {
for (var i = 0; i < preview.length; i += 3) { disc(preview[i].x, preview[i].y, 1.6, 'rgba(255,179,199,0.8)'); }
}
drawTrail(trail, '255,179,199', 3.5, 0.8);
ctx.setLineDash(held ? [4, 3] : []);
drawArms(sim, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 4.5);
ctx.setLineDash([]);
if (held) {
arc(PIVOT_X, PIVOT_Y, 20, Math.PI / 2, Math.PI / 2 - sim[0], '255,241,201');
arc(a.x, a.y, 16, Math.PI / 2, Math.PI / 2 - sim[1], '159,216,255');
seg(PIVOT_X, PIVOT_Y, PIVOT_X, PIVOT_Y + 28, 1, 'rgba(255,255,255,0.4)');
seg(a.x, a.y, a.x, a.y + 24, 1, 'rgba(255,255,255,0.4)');
}
disc(PIVOT_X, PIVOT_Y, 5, '#fff1c9');
glow(a.x, a.y, 20, '201,184,255', 0.5);
disc(a.x, a.y, 8, '#c9b8ff');
glow(b.x, b.y, 24, '255,179,199', 0.6);
disc(b.x, b.y, 10, '#ffb3c7');
if (held) {
ctx.beginPath();
ctx.arc(ringArm === 1 ? a.x : b.x, ringArm === 1 ? a.y : b.y, 15, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (demoDragging) {
ctx.beginPath();
ctx.moveTo(cursor.x, cursor.y);
ctx.lineTo(cursor.x + 9, cursor.y + 4);
ctx.lineTo(cursor.x + 4, cursor.y + 9);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.fill();
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpDragArmSetReleaseAngles() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="포인터나 방향키로 두 팔을 원하는 각도까지 끌어 놓으면 계산이 잠시 멈추고, 놓는 순간 그 각도에서 속도 0으로 다시 출발한다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">처음 자세</button>
<p className={styles.bp__caption}>포인터나 방향키로 두 팔을 원하는 각도까지 끌어 놓으면 계산이 잠시 멈추고, 놓는 순간 그 각도에서 속도 0으로 다시 출발한다.</p>
</div>
</main>
);
}07 · 팔을 끌어다 놓아 출발 각도를 정하는 진자 — 측정 누적면적 61.799% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepFrame() {
if (demo) {
stepDemo();
}
if (held) {
return;
}
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
}
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
function dragTo(x, y) {
if (grab === 1) {
sim[0] = Math.atan2(x - PIVOT_X, y - PIVOT_Y);
}
else if (grab === 2) {
var a = bobOne(sim, PIVOT_X, PIVOT_Y);
sim[1] = Math.atan2(x - a.x, y - a.y);
}
sim[2] = 0;
sim[3] = 0;
trail = [];
if (reduce) {
makePreview();
}
}
08뒤집히는 데 걸린 시간을 색으로 칠한 출발각 지도
GRID 48 의 칸 하나가 출발 각도 한 쌍이고, flipTime(t1, t2) 이 정지 상태로 출발한 진자를 FLIP_DT 0.02 초씩 FLIP_STEPS 500 걸음(10초) 돌려 둘째 팔 각도가 π 를 처음 넘는 시각을 돌려주며(끝내 안 넘으면 -1), flipColor 가 그 시각을 로그 눈금 다섯 색으로 칠하고 안 뒤집히면 어두운 잉크색을 쓴다. 한 프레임에 CELLS_PER_FRAME 36 칸씩 칠해 2304칸을 약 64프레임에 채우며, 가운데 어두운 덩어리는 에너지가 모자라 뒤집히지 않는 영역이고 칸을 누르면 오른쪽에서 그 칸의 진자가 실제로 돈다. 누적면적 43.2%·평균 강도 57.0·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="출발 각도 격자 48×48 칸마다 진자를 돌려 둘째 팔이 처음 뒤집힐 때까지 걸린 시간을 색으로 칠하면, 경계가 끝없이 엉키는 프랙털 지도가 나온다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">계산 중 — 칸마다 뒤집힘 시간을 잰다</p>
<button class="bp__btn" id="bpBtn" type="button">다시 계산</button>
<p class="bp__caption">출발 각도 격자 48×48 칸마다 진자를 돌려 둘째 팔이 처음 뒤집힐 때까지 걸린 시간을 색으로 칠하면, 경계가 끝없이 엉키는 프랙털 지도가 나온다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 출발 각도 격자의 칸마다 진자를 돌려 둘째 팔이 처음 뒤집히는 시간(flipTime)을 재고, 프레임마다 CELLS_PER_FRAME 칸씩 색칠한다
var GRID = 48;
var CELLS_PER_FRAME = 36;
var FLIP_DT = 0.02;
var FLIP_STEPS = 500;
var HOLD_FRAMES = 80;
var CELL_PX = 3.5;
var MAP_X = 14, MAP_Y = 22;
var DT = 0.01;
var SUBSTEPS = 3;
var TRAIL_LEN = 60;
var SEL_HOLD_MS = 1500;
var DEMO_CELLS = [[30, 14], [12, 20], [24, 24], [36, 34], [8, 8], [40, 24]];
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 24, PIVOT_X = 252, PIVOT_Y = 62;
var PREROLL_FRAMES = 0;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
ownInput = true;
var PI = Math.PI;
var STOPS = [[255, 241, 201], [255, 179, 199], [201, 184, 255], [159, 216, 255], [168, 230, 207]];
var mapCv = document.createElement('canvas');
mapCv.width = GRID;
mapCv.height = GRID;
var mctx0 = mapCv.getContext('2d');
if (!mctx0) {
return function () { return; };
}
var mctx = mctx0;
var img = mctx.createImageData(GRID, GRID);
var flipAt = new Float32Array(GRID * GRID);
var done = 0;
var holdLeft = 0;
var selI = 30, selJ = 14;
var mini = [0, 0, 0, 0];
var trail = [];
function startOf(i) { return -PI + (i + 0.5) * 2 * PI / GRID; }
function startOfRow(j) { return PI - (j + 0.5) * 2 * PI / GRID; }
function flipTime(t1, t2) {
var s = [t1, t2, 0, 0];
for (var n = 0; n < FLIP_STEPS; n += 1) {
rk4Step(s, FLIP_DT);
if (Math.abs(s[1]) > PI) {
return (n + 1) * FLIP_DT;
}
}
return -1;
}
function flipColor(t) {
if (t < 0) {
return [42, 36, 66];
}
var u = clamp(Math.log(1 + t * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3), 0, 0.999) * (STOPS.length - 1);
var k = Math.floor(u), f = u - k;
var c0 = STOPS[k], c1 = STOPS[k + 1];
return [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
}
function paintCell(idx) {
var i = idx % GRID, j = Math.floor(idx / GRID);
var t = flipTime(startOf(i), startOfRow(j));
flipAt[idx] = t;
var c = flipColor(t);
img.data[idx * 4] = c[0];
img.data[idx * 4 + 1] = c[1];
img.data[idx * 4 + 2] = c[2];
img.data[idx * 4 + 3] = 255;
}
function select(i, j) {
selI = clamp(i, 0, GRID - 1);
selJ = clamp(j, 0, GRID - 1);
mini = [startOf(selI), startOfRow(selJ), 0, 0];
trail = [];
}
function recompute() {
done = 0;
holdLeft = 0;
for (var p = 0; p < img.data.length; p += 1) {
img.data[p] = 0;
}
setStatus('계산 중 — 칸마다 뒤집힘 시간을 잰다');
if (reduce) {
for (var idx = 0; idx < GRID * GRID; idx += 1) {
paintCell(idx);
}
done = GRID * GRID;
setStatus('계산 끝 — 칸을 눌러 진자를 본다');
}
}
function stepFrame() {
if (done < GRID * GRID) {
var end = Math.min(GRID * GRID, done + CELLS_PER_FRAME);
for (var idx = done; idx < end; idx += 1) {
paintCell(idx);
}
done = end;
if (done >= GRID * GRID) {
holdLeft = HOLD_FRAMES;
setStatus('계산 끝 — 칸을 눌러 진자를 본다');
}
}
else if (demo) {
holdLeft -= 1;
if (holdLeft <= 0) {
recompute();
}
}
if (demo) {
var k = Math.floor(elapsed / SEL_HOLD_MS) % DEMO_CELLS.length;
if (DEMO_CELLS[k][0] !== selI || DEMO_CELLS[k][1] !== selJ) {
select(DEMO_CELLS[k][0], DEMO_CELLS[k][1]);
}
}
if (!reduce) {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(mini, DT);
}
var p = bobTwo(mini, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
}
function previewPath() {
var s = [mini[0], mini[1], 0, 0];
trail = [];
for (var n = 0; n < 90; n += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) {
rk4Step(s, DT);
}
var p = bobTwo(s, PIVOT_X, PIVOT_Y);
trail.push({ x: p.x, y: p.y });
}
}
function choose(i, j) {
stopDemo();
select(i, j);
if (reduce) {
previewPath();
}
setStatus('선택한 출발각으로 진자를 돌렸다');
draw();
}
listen(canvas, 'pointerdown', function (e) {
var pt = canvasPoint(e);
var i = Math.floor((pt.x - MAP_X) / CELL_PX), j = Math.floor((pt.y - MAP_Y) / CELL_PX);
if (i < 0 || j < 0 || i >= GRID || j >= GRID) {
return;
}
choose(i, j);
});
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'ArrowLeft') {
e.preventDefault();
choose(selI - 1, selJ);
}
else if (k === 'ArrowRight') {
e.preventDefault();
choose(selI + 1, selJ);
}
else if (k === 'ArrowUp') {
e.preventDefault();
choose(selI, selJ - 1);
}
else if (k === 'ArrowDown') {
e.preventDefault();
choose(selI, selJ + 1);
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
stopDemo();
recompute();
draw();
}
});
onButton = function () { stopDemo(); recompute(); draw(); };
select(selI, selJ);
recompute();
if (reduce) {
previewPath();
}
function draw() {
var b = bobTwo(mini, PIVOT_X, PIVOT_Y);
beginDraw('#463e68', '#28223f', 90, 100);
var size = GRID * CELL_PX;
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(MAP_X - 3, MAP_Y - 3, size + 6, size + 6);
mctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(mapCv, MAP_X, MAP_Y, size, size);
if (done < GRID * GRID) {
var row = Math.floor(done / GRID);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillRect(MAP_X, MAP_Y + row * CELL_PX, size, CELL_PX);
}
var sx = MAP_X + selI * CELL_PX, sy = MAP_Y + selJ * CELL_PX;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx - 2, sy - 2, CELL_PX + 4, CELL_PX + 4);
glow(b.x, b.y, 54, '255,238,252', 0.3);
drawTrail(trail, '255,179,199', 3, 0.85);
drawArms(mini, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 3.5);
var a = bobOne(mini, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
disc(a.x, a.y, 5, '#c9b8ff');
disc(b.x, b.y, 6.5, '#ffb3c7');
// 색 막대 — 짧은 시간에서 긴 시간으로
var lx = 204, ly = 128, lw = 100;
for (var q = 0; q < lw; q += 2) {
var tt = (Math.exp(q / lw * Math.log(1 + FLIP_STEPS * FLIP_DT * 3)) - 1) / 3;
var c = flipColor(tt);
ctx.fillStyle = 'rgb(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ')';
ctx.fillRect(lx + q, ly, 2, 8);
}
var ft = flipAt[selI + selJ * GRID];
if (done >= GRID * GRID || selJ * GRID + selI < done) {
var tu = ft < 0 ? 1 : Math.log(1 + ft * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3);
disc(lx + clamp(tu, 0, 1) * lw, ly + 4, 3.5, '#ffffff');
}
ctx.fillStyle = 'rgb(42,36,66)';
ctx.fillRect(lx + lw + 6, ly, 8, 8);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpFlipTimeColorMapFractalGrid.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 출발 각도 격자의 칸마다 진자를 돌려 둘째 팔이 처음 뒤집히는 시간(flipTime)을 재고, 프레임마다 CELLS_PER_FRAME 칸씩 색칠한다
var GRID = 48;
var CELLS_PER_FRAME = 36;
var FLIP_DT = 0.02;
var FLIP_STEPS = 500;
var HOLD_FRAMES = 80;
var CELL_PX = 3.5;
var MAP_X = 14, MAP_Y = 22;
var DT = 0.01;
var SUBSTEPS = 3;
var TRAIL_LEN = 60;
var SEL_HOLD_MS = 1500;
var DEMO_CELLS: number[][] = [[30, 14], [12, 20], [24, 24], [36, 34], [8, 8], [40, 24]];
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 24, PIVOT_X = 252, PIVOT_Y = 62;
var PREROLL_FRAMES = 0;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
ownInput = true;
var PI = Math.PI;
var STOPS: number[][] = [[255, 241, 201], [255, 179, 199], [201, 184, 255], [159, 216, 255], [168, 230, 207]];
var mapCv = document.createElement('canvas');
mapCv.width = GRID;
mapCv.height = GRID;
var mctx0 = mapCv.getContext('2d');
if (!mctx0) { return function (): void { return; }; }
var mctx: CanvasRenderingContext2D = mctx0;
var img: ImageData = mctx.createImageData(GRID, GRID);
var flipAt = new Float32Array(GRID * GRID);
var done = 0;
var holdLeft = 0;
var selI = 30, selJ = 14;
var mini: number[] = [0, 0, 0, 0];
var trail: Pt[] = [];
function startOf(i: number): number { return -PI + (i + 0.5) * 2 * PI / GRID; }
function startOfRow(j: number): number { return PI - (j + 0.5) * 2 * PI / GRID; }
function flipTime(t1: number, t2: number): number {
var s = [t1, t2, 0, 0];
for (var n = 0; n < FLIP_STEPS; n += 1) {
rk4Step(s, FLIP_DT);
if (Math.abs(s[1]) > PI) { return (n + 1) * FLIP_DT; }
}
return -1;
}
function flipColor(t: number): number[] {
if (t < 0) { return [42, 36, 66]; }
var u = clamp(Math.log(1 + t * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3), 0, 0.999) * (STOPS.length - 1);
var k = Math.floor(u), f = u - k;
var c0 = STOPS[k], c1 = STOPS[k + 1];
return [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
}
function paintCell(idx: number): void {
var i = idx % GRID, j = Math.floor(idx / GRID);
var t = flipTime(startOf(i), startOfRow(j));
flipAt[idx] = t;
var c = flipColor(t);
img.data[idx * 4] = c[0];
img.data[idx * 4 + 1] = c[1];
img.data[idx * 4 + 2] = c[2];
img.data[idx * 4 + 3] = 255;
}
function select(i: number, j: number): void {
selI = clamp(i, 0, GRID - 1);
selJ = clamp(j, 0, GRID - 1);
mini = [startOf(selI), startOfRow(selJ), 0, 0];
trail = [];
}
function recompute(): void {
done = 0;
holdLeft = 0;
for (var p = 0; p < img.data.length; p += 1) { img.data[p] = 0; }
setStatus('계산 중 — 칸마다 뒤집힘 시간을 잰다');
if (reduce) {
for (var idx = 0; idx < GRID * GRID; idx += 1) { paintCell(idx); }
done = GRID * GRID;
setStatus('계산 끝 — 칸을 눌러 진자를 본다');
}
}
function stepFrame(): void {
if (done < GRID * GRID) {
var end = Math.min(GRID * GRID, done + CELLS_PER_FRAME);
for (var idx = done; idx < end; idx += 1) { paintCell(idx); }
done = end;
if (done >= GRID * GRID) { holdLeft = HOLD_FRAMES; setStatus('계산 끝 — 칸을 눌러 진자를 본다'); }
} else if (demo) {
holdLeft -= 1;
if (holdLeft <= 0) { recompute(); }
}
if (demo) {
var k = Math.floor(elapsed / SEL_HOLD_MS) % DEMO_CELLS.length;
if (DEMO_CELLS[k][0] !== selI || DEMO_CELLS[k][1] !== selJ) { select(DEMO_CELLS[k][0], DEMO_CELLS[k][1]); }
}
if (!reduce) {
for (var i = 0; i < SUBSTEPS; i += 1) { rk4Step(mini, DT); }
var p = bobTwo(mini, PIVOT_X, PIVOT_Y);
pushTrail(trail, p.x, p.y, TRAIL_LEN);
}
}
function previewPath(): void {
var s = [mini[0], mini[1], 0, 0];
trail = [];
for (var n = 0; n < 90; n += 1) {
for (var k = 0; k < SUBSTEPS; k += 1) { rk4Step(s, DT); }
var p = bobTwo(s, PIVOT_X, PIVOT_Y);
trail.push({ x: p.x, y: p.y });
}
}
function choose(i: number, j: number): void {
stopDemo();
select(i, j);
if (reduce) { previewPath(); }
setStatus('선택한 출발각으로 진자를 돌렸다');
draw();
}
listen(canvas, 'pointerdown', function (e: Event): void {
var pt = canvasPoint(e as PointerEvent);
var i = Math.floor((pt.x - MAP_X) / CELL_PX), j = Math.floor((pt.y - MAP_Y) / CELL_PX);
if (i < 0 || j < 0 || i >= GRID || j >= GRID) { return; }
choose(i, j);
});
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'ArrowLeft') { e.preventDefault(); choose(selI - 1, selJ); }
else if (k === 'ArrowRight') { e.preventDefault(); choose(selI + 1, selJ); }
else if (k === 'ArrowUp') { e.preventDefault(); choose(selI, selJ - 1); }
else if (k === 'ArrowDown') { e.preventDefault(); choose(selI, selJ + 1); }
else if (k === 'Enter' || k === ' ') { e.preventDefault(); stopDemo(); recompute(); draw(); }
});
onButton = function (): void { stopDemo(); recompute(); draw(); };
select(selI, selJ);
recompute();
if (reduce) { previewPath(); }
function draw(): void {
var b = bobTwo(mini, PIVOT_X, PIVOT_Y);
beginDraw('#463e68', '#28223f', 90, 100);
var size = GRID * CELL_PX;
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(MAP_X - 3, MAP_Y - 3, size + 6, size + 6);
mctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(mapCv, MAP_X, MAP_Y, size, size);
if (done < GRID * GRID) {
var row = Math.floor(done / GRID);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillRect(MAP_X, MAP_Y + row * CELL_PX, size, CELL_PX);
}
var sx = MAP_X + selI * CELL_PX, sy = MAP_Y + selJ * CELL_PX;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx - 2, sy - 2, CELL_PX + 4, CELL_PX + 4);
glow(b.x, b.y, 54, '255,238,252', 0.3);
drawTrail(trail, '255,179,199', 3, 0.85);
drawArms(mini, PIVOT_X, PIVOT_Y, '#c9b8ff', '#9fd8ff', 3.5);
var a = bobOne(mini, PIVOT_X, PIVOT_Y);
disc(PIVOT_X, PIVOT_Y, 4, '#fff1c9');
disc(a.x, a.y, 5, '#c9b8ff');
disc(b.x, b.y, 6.5, '#ffb3c7');
// 색 막대 — 짧은 시간에서 긴 시간으로
var lx = 204, ly = 128, lw = 100;
for (var q = 0; q < lw; q += 2) {
var tt = (Math.exp(q / lw * Math.log(1 + FLIP_STEPS * FLIP_DT * 3)) - 1) / 3;
var c = flipColor(tt);
ctx.fillStyle = 'rgb(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ')';
ctx.fillRect(lx + q, ly, 2, 8);
}
var ft = flipAt[selI + selJ * GRID];
if (done >= GRID * GRID || selJ * GRID + selI < done) {
var tu = ft < 0 ? 1 : Math.log(1 + ft * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3);
disc(lx + clamp(tu, 0, 1) * lw, ly + 4, 3.5, '#ffffff');
}
ctx.fillStyle = 'rgb(42,36,66)';
ctx.fillRect(lx + lw + 6, ly, 8, 8);
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpFlipTimeColorMapFractalGrid() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="출발 각도 격자 48×48 칸마다 진자를 돌려 둘째 팔이 처음 뒤집힐 때까지 걸린 시간을 색으로 칠하면, 경계가 끝없이 엉키는 프랙털 지도가 나온다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">계산 중 — 칸마다 뒤집힘 시간을 잰다</p>
<button className={styles.bp__btn} ref={btnRef} type="button">다시 계산</button>
<p className={styles.bp__caption}>출발 각도 격자 48×48 칸마다 진자를 돌려 둘째 팔이 처음 뒤집힐 때까지 걸린 시간을 색으로 칠하면, 경계가 끝없이 엉키는 프랙털 지도가 나온다.</p>
</div>
</main>
);
}08 · 뒤집히는 데 걸린 시간을 색으로 칠한 출발각 지도 — 측정 누적면적 43.201% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function startOf(i) { return -PI + (i + 0.5) * 2 * PI / GRID; }
function startOfRow(j) { return PI - (j + 0.5) * 2 * PI / GRID; }
function flipTime(t1, t2) {
var s = [t1, t2, 0, 0];
for (var n = 0; n < FLIP_STEPS; n += 1) {
rk4Step(s, FLIP_DT);
if (Math.abs(s[1]) > PI) {
return (n + 1) * FLIP_DT;
}
}
return -1;
}
function flipColor(t) {
if (t < 0) {
return [42, 36, 66];
}
var u = clamp(Math.log(1 + t * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3), 0, 0.999) * (STOPS.length - 1);
var k = Math.floor(u), f = u - k;
var c0 = STOPS[k], c1 = STOPS[k + 1];
return [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
}
09황동 책상 장난감 위에 발광 궤적을 남기는 히어로
brassSeg 가 어두운 외곽·황동 본체·하이라이트 세 겹으로 팔을 그리고 brassBall 이 방사형 그라디언트로 구체를 그리며, shadowOf 는 관절의 책상 위 높이 h = DESK_Y - p.y 에 비례해 그림자를 옆으로 SHADOW_SKEW(0.42)×h 만큼 밀고 높을수록 크고 옅게 그린다. 끝 추가 궤적은 lighter 합성으로 GLOW_TRAIL_MAX 300 점까지 겹쳐 빛난다. 누적면적 48.5%·평균 강도 40.5·23/23 프레임이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="받침대에 매달린 황동 두 팔 장난감 위에서 끝 추가가 빛나는 궤적을 남기고 관절마다 높이에 맞춰 늘어나는 그림자가 바닥에 따라 움직이는 책상 위 장식품 한 장면으로 묶는다."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">시연 중</p>
<button class="bp__btn" id="bpBtn" type="button">멈춤</button>
<p class="bp__caption">받침대에 매달린 황동 두 팔 장난감 위에서 끝 추가가 빛나는 궤적을 남기고 관절마다 높이에 맞춰 늘어나는 그림자가 바닥에 따라 움직이는 책상 위 장식품 한 장면으로 묶는다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.bp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.bp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption, .bp__btn {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var statusEl = document.getElementById('bpStatus');
var btnEl = document.getElementById('bpBtn');
"use strict";
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var wrap = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo)
root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg) { statusEl.textContent = msg; }
function hsl(h, s, l, a) {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner, outer, gx, gy) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1, y1, x2, y2, w, col) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x, y, r, col) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x, y, r, rgb, a) {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr, rgb, w, alpha) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr, x, y, max) {
arr.push({ x: x, y: y });
if (arr.length > max) {
arr.shift();
}
}
function defaultToggle() {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) {
elapsed += FIXED_DT;
stepFrame();
}
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton = defaultToggle;
// 기법: 같은 RK4 진자 위에 황동 질감(그라디언트 구체·선형 하이라이트)과 높이에 비례한 바닥 그림자, 가산 합성 궤적을 얹는다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.1;
var START_T2 = 2.7;
var GLOW_TRAIL_MAX = 300;
var SHADOW_SKEW = 0.42;
var DESK_Y = 164;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 36, PIVOT_X = 186, PIVOT_Y = 74;
var PREROLL_FRAMES = 90;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1, t2, w1, w2) {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s, h) {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a;
u2 = t2 + h2 * k2b;
u3 = w1 + h2 * k2c;
u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a;
u2 = t2 + h * k3b;
u3 = w1 + h * k3c;
u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s, ox, oy) {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s, ox, oy) {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s, ox, oy, c1, c2, w) {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var sim = [START_T1, START_T2, 0, 0];
var glowTrail = [];
function stepFrame() {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
glowTrail.push({ x: p.x, y: p.y });
if (glowTrail.length > GLOW_TRAIL_MAX) {
glowTrail.shift();
}
}
}
function shadowOf(p, r) {
var h = clamp(DESK_Y - p.y, 0, 200);
var sx = p.x + h * SHADOW_SKEW;
var sy = DESK_Y + 8 + h * 0.07;
var k = 1 + h * 0.012;
ctx.save();
ctx.translate(sx, sy);
ctx.scale(1, 0.28);
ctx.beginPath();
ctx.arc(0, 0, r * k, 0, 6.2832);
ctx.fillStyle = 'rgba(8,6,16,' + (0.42 / k) + ')';
ctx.fill();
ctx.restore();
}
function brassSeg(x1, y1, x2, y2, w) {
seg(x1, y1, x2, y2, w + 2, '#6f5630');
seg(x1, y1, x2, y2, w, '#d4b077');
var dx = x2 - x1, dy = y2 - y1;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len;
seg(x1 + nx * w * 0.22, y1 + ny * w * 0.22, x2 + nx * w * 0.22, y2 + ny * w * 0.22, w * 0.3, '#f6e7c1');
}
function brassBall(x, y, r) {
var g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.1, x, y, r);
g.addColorStop(0, '#fff6d9');
g.addColorStop(0.45, '#e2bd7c');
g.addColorStop(1, '#8a6a38');
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = g;
ctx.fill();
ctx.strokeStyle = 'rgba(60,40,16,0.55)';
ctx.lineWidth = 1;
ctx.stroke();
}
function draw() {
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#4a3f64', '#241f38', b.x, b.y);
// 책상 면과 받침대
var desk = ctx.createLinearGradient(0, DESK_Y, 0, CALC_H);
desk.addColorStop(0, '#3b3150');
desk.addColorStop(1, '#262038');
ctx.fillStyle = desk;
ctx.fillRect(0, DESK_Y, CALC_W, CALC_H - DESK_Y);
seg(0, DESK_Y, CALC_W, DESK_Y, 1, 'rgba(255,241,201,0.2)');
shadowOf({ x: PIVOT_X, y: PIVOT_Y }, 5);
shadowOf(a, 7);
shadowOf(b, 9);
var armShadowA = Math.hypot(a.x - PIVOT_X, a.y - PIVOT_Y);
if (armShadowA > 0) {
var hs = clamp(DESK_Y - PIVOT_Y, 0, 200);
ctx.beginPath();
ctx.moveTo(PIVOT_X + hs * SHADOW_SKEW, DESK_Y + 8 + hs * 0.07);
ctx.lineTo(a.x + clamp(DESK_Y - a.y, 0, 200) * SHADOW_SKEW, DESK_Y + 8 + clamp(DESK_Y - a.y, 0, 200) * 0.07);
ctx.lineTo(b.x + clamp(DESK_Y - b.y, 0, 200) * SHADOW_SKEW, DESK_Y + 8 + clamp(DESK_Y - b.y, 0, 200) * 0.07);
ctx.strokeStyle = 'rgba(8,6,16,0.28)';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.stroke();
}
ctx.save();
ctx.translate(96, DESK_Y + 8);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, 54, 0, 6.2832);
ctx.fillStyle = 'rgba(8,6,16,0.45)';
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(96, DESK_Y + 2);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, 48, 0, 6.2832);
var baseG = ctx.createLinearGradient(-48, 0, 48, 0);
baseG.addColorStop(0, '#8a6a38');
baseG.addColorStop(0.35, '#f1dba6');
baseG.addColorStop(1, '#a8834a');
ctx.fillStyle = baseG;
ctx.fill();
ctx.restore();
brassSeg(96, DESK_Y, 96, PIVOT_Y, 6);
brassSeg(96, PIVOT_Y, PIVOT_X, PIVOT_Y, 5);
// 발광 궤적 — 가산 합성
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (var i = 1; i < glowTrail.length; i += 1) {
var f = i / glowTrail.length;
ctx.beginPath();
ctx.moveTo(glowTrail[i - 1].x, glowTrail[i - 1].y);
ctx.lineTo(glowTrail[i].x, glowTrail[i].y);
ctx.strokeStyle = hsl(40 + f * 20, 90, 70, 0.55 * f * f);
ctx.lineWidth = 1 + 4 * f;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
glow(b.x, b.y, 56, '255,214,150', 0.4);
brassSeg(PIVOT_X, PIVOT_Y, a.x, a.y, 4.5);
brassSeg(a.x, a.y, b.x, b.y, 4.5);
brassBall(PIVOT_X, PIVOT_Y, 5.5);
brassBall(a.x, a.y, 7.5);
brassBall(b.x, b.y, 10);
glow(b.x, b.y, 18, '255,244,214', 0.5);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
stepFrame();
accMs -= FIXED_DT;
}
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) {
elapsed += FIXED_DT;
stepFrame();
}
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function () { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
}
if (reduce) {
if (!ownInput) {
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
}
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./DpHeroBrassDeskToyScene.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
type Pt = { x: number; y: number };
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var wrap: HTMLElement = canvas.parentElement ? canvas.parentElement : root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var REDUCE_STEP_FRAMES = 40;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
var paused = false;
var ownInput = false;
var cw = 480, ch = 300, sc = 1.5, elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
function hsl(h: number, s: number, l: number, a: number): string {
return 'hsla(' + (h % 360) + ',' + s + '%,' + l + '%,' + a + ')';
}
function canvasPoint(e: PointerEvent): Pt {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
// 배경 — 어두운 잉크색 위에 방사형 빛무리를 (gx, gy) 에 깐다
function beginDraw(inner: string, outer: string, gx: number, gy: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(gx, gy, 8, gx, gy, 230);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function seg(x1: number, y1: number, x2: number, y2: number, w: number, col: string): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineCap = 'round';
ctx.lineWidth = w;
ctx.strokeStyle = col;
ctx.stroke();
}
function disc(x: number, y: number, r: number, col: string): void {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = col;
ctx.fill();
}
function glow(x: number, y: number, r: number, rgb: string, a: number): void {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function drawTrail(arr: Pt[], rgb: string, w: number, alpha: number): void {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (alpha * f * f) + ')';
ctx.lineWidth = w * (0.3 + 0.7 * f);
ctx.stroke();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number): void {
arr.push({ x: x, y: y });
if (arr.length > max) { arr.shift(); }
}
function defaultToggle(): void {
if (reduce) {
for (var i = 0; i < REDUCE_STEP_FRAMES; i += 1) { elapsed += FIXED_DT; stepFrame(); }
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
var onButton: () => void = defaultToggle;
// 기법: 같은 RK4 진자 위에 황동 질감(그라디언트 구체·선형 하이라이트)과 높이에 비례한 바닥 그림자, 가산 합성 궤적을 얹는다
var DT = 0.01;
var SUBSTEPS = 3;
var START_T1 = 2.1;
var START_T2 = 2.7;
var GLOW_TRAIL_MAX = 300;
var SHADOW_SKEW = 0.42;
var DESK_Y = 164;
var L1 = 1.0;
var L2 = 1.0;
var M1 = 1.0;
var M2 = 1.0;
var G = 9.81;
var UNIT = 36, PIVOT_X = 186, PIVOT_Y = 74;
var PREROLL_FRAMES = 90;
// 라그랑주 운동방정식 — 각도는 아래 방향이 0, 시계 방향이 양수
function accTheta1(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L1 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2)
- 2 * Math.sin(d) * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * Math.cos(d))) / den;
}
function accTheta2(t1: number, t2: number, w1: number, w2: number): number {
var d = t1 - t2;
var den = L2 * (2 * M1 + M2 - M2 * Math.cos(2 * d));
return (2 * Math.sin(d) * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1)
+ w2 * w2 * L2 * M2 * Math.cos(d))) / den;
}
// 룽게-쿠타 4단계 — 한 걸음을 네 번 기울기로 평균 낸다 (s = [t1, t2, w1, w2])
function rk4Step(s: number[], h: number): void {
var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
var h2 = h * 0.5;
var k1a = w1, k1b = w2, k1c = accTheta1(t1, t2, w1, w2), k1d = accTheta2(t1, t2, w1, w2);
var u1 = t1 + h2 * k1a, u2 = t2 + h2 * k1b, u3 = w1 + h2 * k1c, u4 = w2 + h2 * k1d;
var k2a = u3, k2b = u4, k2c = accTheta1(u1, u2, u3, u4), k2d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h2 * k2a; u2 = t2 + h2 * k2b; u3 = w1 + h2 * k2c; u4 = w2 + h2 * k2d;
var k3a = u3, k3b = u4, k3c = accTheta1(u1, u2, u3, u4), k3d = accTheta2(u1, u2, u3, u4);
u1 = t1 + h * k3a; u2 = t2 + h * k3b; u3 = w1 + h * k3c; u4 = w2 + h * k3d;
var k4a = u3, k4b = u4, k4c = accTheta1(u1, u2, u3, u4), k4d = accTheta2(u1, u2, u3, u4);
s[0] = t1 + h / 6 * (k1a + 2 * k2a + 2 * k3a + k4a);
s[1] = t2 + h / 6 * (k1b + 2 * k2b + 2 * k3b + k4b);
s[2] = w1 + h / 6 * (k1c + 2 * k2c + 2 * k3c + k4c);
s[3] = w2 + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d);
}
function bobOne(s: number[], ox: number, oy: number): Pt {
return { x: ox + UNIT * L1 * Math.sin(s[0]), y: oy + UNIT * L1 * Math.cos(s[0]) };
}
function bobTwo(s: number[], ox: number, oy: number): Pt {
var a = bobOne(s, ox, oy);
return { x: a.x + UNIT * L2 * Math.sin(s[1]), y: a.y + UNIT * L2 * Math.cos(s[1]) };
}
function drawArms(s: number[], ox: number, oy: number, c1: string, c2: string, w: number): void {
var a = bobOne(s, ox, oy), b = bobTwo(s, ox, oy);
seg(ox, oy, a.x, a.y, w, c1);
seg(a.x, a.y, b.x, b.y, w, c2);
}
var sim: number[] = [START_T1, START_T2, 0, 0];
var glowTrail: Pt[] = [];
function stepFrame(): void {
for (var i = 0; i < SUBSTEPS; i += 1) {
rk4Step(sim, DT);
var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
glowTrail.push({ x: p.x, y: p.y });
if (glowTrail.length > GLOW_TRAIL_MAX) { glowTrail.shift(); }
}
}
function shadowOf(p: Pt, r: number): void {
var h = clamp(DESK_Y - p.y, 0, 200);
var sx = p.x + h * SHADOW_SKEW;
var sy = DESK_Y + 8 + h * 0.07;
var k = 1 + h * 0.012;
ctx.save();
ctx.translate(sx, sy);
ctx.scale(1, 0.28);
ctx.beginPath();
ctx.arc(0, 0, r * k, 0, 6.2832);
ctx.fillStyle = 'rgba(8,6,16,' + (0.42 / k) + ')';
ctx.fill();
ctx.restore();
}
function brassSeg(x1: number, y1: number, x2: number, y2: number, w: number): void {
seg(x1, y1, x2, y2, w + 2, '#6f5630');
seg(x1, y1, x2, y2, w, '#d4b077');
var dx = x2 - x1, dy = y2 - y1;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len;
seg(x1 + nx * w * 0.22, y1 + ny * w * 0.22, x2 + nx * w * 0.22, y2 + ny * w * 0.22, w * 0.3, '#f6e7c1');
}
function brassBall(x: number, y: number, r: number): void {
var g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.1, x, y, r);
g.addColorStop(0, '#fff6d9');
g.addColorStop(0.45, '#e2bd7c');
g.addColorStop(1, '#8a6a38');
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fillStyle = g;
ctx.fill();
ctx.strokeStyle = 'rgba(60,40,16,0.55)';
ctx.lineWidth = 1;
ctx.stroke();
}
function draw(): void {
var a = bobOne(sim, PIVOT_X, PIVOT_Y), b = bobTwo(sim, PIVOT_X, PIVOT_Y);
beginDraw('#4a3f64', '#241f38', b.x, b.y);
// 책상 면과 받침대
var desk = ctx.createLinearGradient(0, DESK_Y, 0, CALC_H);
desk.addColorStop(0, '#3b3150');
desk.addColorStop(1, '#262038');
ctx.fillStyle = desk;
ctx.fillRect(0, DESK_Y, CALC_W, CALC_H - DESK_Y);
seg(0, DESK_Y, CALC_W, DESK_Y, 1, 'rgba(255,241,201,0.2)');
shadowOf({ x: PIVOT_X, y: PIVOT_Y }, 5);
shadowOf(a, 7);
shadowOf(b, 9);
var armShadowA = Math.hypot(a.x - PIVOT_X, a.y - PIVOT_Y);
if (armShadowA > 0) {
var hs = clamp(DESK_Y - PIVOT_Y, 0, 200);
ctx.beginPath();
ctx.moveTo(PIVOT_X + hs * SHADOW_SKEW, DESK_Y + 8 + hs * 0.07);
ctx.lineTo(a.x + clamp(DESK_Y - a.y, 0, 200) * SHADOW_SKEW, DESK_Y + 8 + clamp(DESK_Y - a.y, 0, 200) * 0.07);
ctx.lineTo(b.x + clamp(DESK_Y - b.y, 0, 200) * SHADOW_SKEW, DESK_Y + 8 + clamp(DESK_Y - b.y, 0, 200) * 0.07);
ctx.strokeStyle = 'rgba(8,6,16,0.28)';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.stroke();
}
ctx.save();
ctx.translate(96, DESK_Y + 8);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, 54, 0, 6.2832);
ctx.fillStyle = 'rgba(8,6,16,0.45)';
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(96, DESK_Y + 2);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, 48, 0, 6.2832);
var baseG = ctx.createLinearGradient(-48, 0, 48, 0);
baseG.addColorStop(0, '#8a6a38');
baseG.addColorStop(0.35, '#f1dba6');
baseG.addColorStop(1, '#a8834a');
ctx.fillStyle = baseG;
ctx.fill();
ctx.restore();
brassSeg(96, DESK_Y, 96, PIVOT_Y, 6);
brassSeg(96, PIVOT_Y, PIVOT_X, PIVOT_Y, 5);
// 발광 궤적 — 가산 합성
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (var i = 1; i < glowTrail.length; i += 1) {
var f = i / glowTrail.length;
ctx.beginPath();
ctx.moveTo(glowTrail[i - 1].x, glowTrail[i - 1].y);
ctx.lineTo(glowTrail[i].x, glowTrail[i].y);
ctx.strokeStyle = hsl(40 + f * 20, 90, 70, 0.55 * f * f);
ctx.lineWidth = 1 + 4 * f;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
glow(b.x, b.y, 56, '255,214,150', 0.4);
brassSeg(PIVOT_X, PIVOT_Y, a.x, a.y, 4.5);
brassSeg(a.x, a.y, b.x, b.y, 4.5);
brassBall(PIVOT_X, PIVOT_Y, 5.5);
brassBall(a.x, a.y, 7.5);
brassBall(b.x, b.y, 10);
glow(b.x, b.y, 18, '255,244,214', 0.5);
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw;
canvas.height = ch;
sc = cw / CALC_W;
draw();
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) { elapsed += FIXED_DT; stepFrame(); accMs -= FIXED_DT; }
}
draw();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < PREROLL_FRAMES; w0 += 1) { elapsed += FIXED_DT; stepFrame(); }
fit();
listen(window, 'resize', fit);
listen(btnEl, 'click', function (): void { onButton(); });
if (!ownInput) {
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
}
if (reduce) {
if (!ownInput) { btnEl.textContent = '다음 장면'; setStatus('한 장면씩 넘기는 중'); }
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function DpHeroBrassDeskToyScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="받침대에 매달린 황동 두 팔 장난감 위에서 끝 추가가 빛나는 궤적을 남기고 관절마다 높이에 맞춰 늘어나는 그림자가 바닥에 따라 움직이는 책상 위 장식품 한 장면으로 묶는다." />
<p className={styles.bp__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.bp__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.bp__caption}>받침대에 매달린 황동 두 팔 장난감 위에서 끝 추가가 빛나는 궤적을 남기고 관절마다 높이에 맞춰 늘어나는 그림자가 바닥에 따라 움직이는 책상 위 장식품 한 장면으로 묶는다.</p>
</div>
</main>
);
}09 · 황동 책상 장난감 위에 발광 궤적을 남기는 히어로 — 측정 누적면적 48.535% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shadowOf(p, r) {
var h = clamp(DESK_Y - p.y, 0, 200);
var sx = p.x + h * SHADOW_SKEW;
var sy = DESK_Y + 8 + h * 0.07;
var k = 1 + h * 0.012;
ctx.save();
ctx.translate(sx, sy);
ctx.scale(1, 0.28);
ctx.beginPath();
ctx.arc(0, 0, r * k, 0, 6.2832);
ctx.fillStyle = 'rgba(8,6,16,' + (0.42 / k) + ')';
ctx.fill();
ctx.restore();
}
function brassSeg(x1, y1, x2, y2, w) {
seg(x1, y1, x2, y2, w + 2, '#6f5630');
seg(x1, y1, x2, y2, w, '#d4b077');
var dx = x2 - x1, dy = y2 - y1;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len;
seg(x1 + nx * w * 0.22, y1 + ny * w * 0.22, x2 + nx * w * 0.22, y2 + ny * w * 0.22, w * 0.3, '#f6e7c1');
}
값이 장면을 바꾸는 곳 — 팔 길이·질량·시간 간격
장면의 인상을 크게 바꾸는 값은 셋이다. 팔 길이 L1·L2 는 끝 추가가 그리는 길의 크기와 속도를, 시간 간격 DT(와 프레임당 걸음 수 SUBSTEPS)는 계산이 실제 운동에서 벗어나는 정도를, 03 의 OFFSET 은 두 진자가 갈라서는 시각을 정한다. 질량 M1·M2 는 이 편에서 전부 1.0 으로 두었고 바꿔서 측정하지는 않았다.
장면별 측정값을 표로 모았다. 03 은 끝 추가 둘이 넓은 궤적을 남기고 그래프까지 움직여 가장 넓고, 04 는 그림이 192 계산 픽셀 정사각형 평면 안에만 그려져 가장 좁다. 모든 장면이 23프레임 전부에서 움직였다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 RK4 기본 진자 | 62.4% | 34.1 | 23/23 |
| 02 잉크 궤적 | 62.4% | 31.4 | 23/23 |
| 03 쌍둥이 발산 | 62.9% | 36.3 | 23/23 |
| 04 위상 평면 | 32.4% | 54.5 | 23/23 |
| 05 에너지 계기 | 53.6% | 39.4 | 23/23 |
| 06 열여섯 부채꼴 | 62.5% | 38.6 | 23/23 |
| 07 끌어 놓기 | 61.8% | 43.6 | 23/23 |
| 08 뒤집힘 지도 | 43.2% | 57.0 | 23/23 |
| 09 황동 히어로 | 48.5% | 40.5 | 23/23 |
어디서 깨지는가 — 큰 시간 간격·에너지 누적 오차·좁은 화면
가장 먼저 깨지는 것은 시간 간격이다. 01 의 DT 를 0.01 에서 0.05 로 키우면 20초 동안 RK4 의 에너지 오차가 최대 0.03% 에서 13.5% 로 커지고, 0.1 이면 140.7% 까지 간다. 프레임당 걸음 수는 그대로라 장면이 다섯 배 빨라지기도 해서, 값을 키워 속도를 올리려다 계산이 틀어진 진자를 보게 된다. 계산법이 거친 05 의 오일러는 같은 DT 에서도 1초에 벌써 +20% 어긋난다.
화면이 좁아져도 계산은 달라지지 않는다. 캔버스는 CSS 로 비율만 줄고, 계산 좌표는 CALC_W 320 · CALC_H 200 에 고정이며, 07 의 포인터 좌표도 캔버스 크기 대비 비율로 이 좌표에 환산한다. 아홉 장면의 폰 폭 넘침 측정은 모두 없음이었다. 값을 직접 바꿔 보려고 압축 파일을 풀 때는 비밀번호 jep9f6eb 을 넣는다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 팔 길이 비율 바꾸기 | 02 L2 1.0 → 0.6 | 둘째 팔을 1.0 에서 0.6 으로 줄이면 15초 시뮬레이션 동안 둘째 팔의 최대 각속도가 13.6 에서 20.7 rad/s 로 올라 끝 추가가 더 빠르게 돌고, 끝이 지나간 길이는 86 에서 77 로 짧아진다. |
| 시간 간격 키우기 | 01 DT 0.01 → 0.05 | 시간 간격을 0.05 로 키우면 같은 20초 동안 룽게-쿠타의 에너지 오차가 최대 0.03% 에서 13.5% 로 커져 진자의 운동이 실제와 어긋나고, 프레임마다 도는 걸음 수는 그대로라 장면도 다섯 배 빨리 흐른다. |
| 출발 오차 크기 조정 | 03 OFFSET 1e-6 → 1e-3 | 출발 오차를 1e-3 으로 키우면 두 끝 추가 사이 거리가 0.1 을 넘는 시각이 시뮬레이션 8.5초에서 4.0초로 앞당겨져, 갈라지는 순간이 절반 가까이 일찍 온다. |
prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고 정지 화면 한 장을 즉시 그린다. 01~06·09 는 장면마다 PREROLL_FRAMES 만큼 미리 돌려 그리고, 버튼을 누르거나 캔버스에서 Enter·Space 를 누르면 40프레임을 한꺼번에 진행해 다시 그리며 상태 줄(aria-live="polite")이 「한 장면씩 넘기는 중」 으로 알린다. 07 은 적분 없이 끌기·키 입력 즉시 150걸음 분량의 끝 추가 경로를 점선으로 미리 그리고, 08 은 2304칸을 시작할 때 한 번에 계산해 칸을 고르면 그 칸의 경로를 점선으로 보인다. 캔버스는 tabindex 0 이고 버튼은 button 요소라 키보드만으로 조작된다.
이중 진자는 왜 예측할 수 없는가?
이 코드에서는 식이 결정적이라 출발 각도가 완전히 같으면 길도 완전히 같다. 여기서 예측하기 어렵다는 말은 출발 각도를 완벽하게 알 수 없다는 뜻이고, 그 작은 차이가 계산을 거치며 커지기 때문이다. 03 이 이 성질을 숫자로 보인다. 거리가 1초에 1.8e-6 이던 것이 5초에 4.7e-4, 7초에 1.5e-2, 9초에 약 1.2 로, 2~4초마다 30배 넘게 커져 처음 백만분의 일 차이가 한 팔 길이만 한 차이가 된다. 08 의 지도에서 색 경계가 복잡하게 엉키는 것도 같은 현상이다. 출발 각도가 조금만 달라도 뒤집히는 시각이 달라지는 칸이 많기 때문이다.
그래서 이 코드에서는 먼 미래까지 같은 길을 얻으려면 계산식보다 출발값을 같게 맞추는 것이 먼저다. 같은 캔버스 방식으로 픽셀까지 다루는 장면은 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과에, 로렌츠 어트랙터 쪽의 카오스는 로렌츠 어트랙터 9종 — 나비 모양 카오스 궤적을 미적분 한 걸음씩 계산해 쌓는 캔버스에 있다. 쓰인 그리기 함수는 Canvas API 문서와 requestAnimationFrame 문서에서 읽을 수 있다.
이중 진자 장면을 쓰기 전에 자주 묻는 질문
이중 진자 실험을 내 페이지에서 해 보려면 무엇부터 바꾸나?
07 을 열어 출발 각도를 끌어서 정해 보고, 코드에서는 START_T1·START_T2 를 바꾸면 된다. 갈라지는 시각을 보고 싶으면 03 의 OFFSET 을 1e-6 에서 1e-3 으로 키워 보라. 거리 0.1 을 넘는 시각이 8.47초에서 4.04초로 앞당겨진다.
외부 라이브러리나 이미지가 필요한가?
필요 없다. 아홉 장면 모두 캔버스 2d 와 한 파일의 스크립트만으로 돌아가고, 외부 주소를 부르는 줄도 난수 함수도 없어서 복사해 붙이면 바로 같은 그림이 나온다.
진자가 갑자기 튀거나 에너지가 늘어나면 어디를 고치나?
DT 를 줄이거나 SUBSTEPS 를 늘려 한 걸음의 크기를 작게 한다. 01 의 변형에서 보았듯 DT 0.05 에서는 에너지 오차가 최대 13.5% 였고 0.01 에서는 0.03% 였다. 계산법도 확인하라. 05 처럼 오일러로 바꾸면 같은 DT 에서 에너지가 처음 1초에 벌써 +20% 불어난다.









