二重振り子 シミュレーション 9パターン — 誤差ひとつが道を分けるカオス
出発角度を百万分の一ラジアン変えるだけで先端はまったく別の道を描く — 03番は二つの振り子を重ねてその分かれ道を目の前で見せる。以下の九つの二重振り子はキャンバス2dでフレームごとに計算して描いたもので、ファイルをダウンロードすればそのまま動きます。
二重振り子の九つの場面をこのページで動かして確かめる
下のグリッドのキャンバスは、ページを読み込んだ時点から動いています。九つとも「支点から一本目の腕が下がり、その先にもう一本の腕が下がる」という同じ形から始まります。二本目の腕の先についた重りを、この記事では「先端」と呼びます。人が触って出発角度を決められるのは07番だけで、ポインターか矢印キーを使います。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 二本の腕の角加速度を解いて一歩ずつ進む振り子
- 02 先端の軌跡がインクのように薄れる跡
- 03 髪の毛一本の差で分かれる双子の振り子
- 04 二つの角度で描く位相平面の軌跡
- 05 計算法ごとのエネルギーのずれを測る比較メーター
- 06 十六の振り子が扇形に広がる色の帯
- 07 腕をドラッグして出発角度を決める振り子
- 08 ひっくり返るまでの時間で塗った出発角マップ
- 09 真鍮の卓上トイに光る軌跡を残すヒーロー
二重振り子 シミュレーション 9パターン — 積分の一段が混沌の形を決める
九つの場面の根は同じ計算一式です。二本の腕の角度を accTheta1・accTheta2 という二つの式で加速度に直し、rk4Step がルンゲ=クッタ法(RK4)の四段階で一歩ずつ先へ進めます。二重振り子 運動方程式を読み解く必要はなく、この二つの式をそのまま呼ぶだけで済みます。腕の長さ L1・L2 と質量 M1・M2 はすべて1.0、重力 G は9.81で、角度は真下を0とします。乱数も時計関数も使わないので、同じ出発角度なら毎回まったく同じ道になります。
01は骨組みをそのまま見せ、02は先端が通ってきた道、04は二つの角度を一枚の平面に描きます。03と06は出発角度をほんの少しずつずらした振り子を何本も重ね、05は計算法を二つ並べて比べ、07は人が出発角度を決めます。08は出発角度の全体を地図として塗り、09はそれらを机の上の真鍮のおもちゃ一つにまとめます。
振り子が動く場面は画面の中で実時間より速く進みます。毎フレーム SUBSTEPS 歩を DT 0.01秒ずつ進めるのでフレームあたり0.03秒(03は SUBSTEPS が4で0.04秒)で、固定時間のアキュムレーターが16ミリ秒ごとに一フレームを出すため、実時間のおよそ2倍(03は約2.5倍)の速さです。ライブラリ・WebGL・外部アドレスは一つも使っていません。近い物理の場面は跳ねるボール アニメーション 9選 — 衝突と重力の物理計算とコマ 回転 9パターン — 角速度と歳差で描く回転シーンにあります。
技法別に見る振り子計算のコード
以下の断片はデモのソースから連続した行を順番どおりに切り出したものです。共通の式(accTheta1・accTheta2・rk4Step)は九つとも同じなので01で一度だけ載せ、残りは場面ごとに違う行を選びました。完結したファイルは暗号化zipに入っています。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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)乗してアルファと太さに掛けるので、古い線ほど細く透明になります。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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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)ずつ進めるので、画面では開いてまもなく分かれ、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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)の暗い四角を重ねて古い線を消し、線の色は elapsed / 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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点がつき、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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で離す・つかむを切り替えます。誰も触らないと 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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のポインター座標もキャンバスの大きさに対する比率でこの座標に換算します。九つの場面のスマホ幅のはみ出し測定は、すべてなしでした。値を自分で変えて試すときは、zipを展開するのにパスワードjep9f6ebを入力してください。
変形3つとアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 腕の長さの比を変える | 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(30〜150フレーム)の分だけ先に回した静止画を一枚すぐに描き、ボタン・キャンバスのクリック・Enter・Spaceを一度押すと40フレームをまとめて進めて描き直します。このときは状態行(aria-live="polite")が「一場面ずつ送っています」と知らせます。07と08は onButton を別の動きに置き換えているので、この40フレーム送りはありません。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種 — 蝶の形のカオス軌道を1歩ずつ数値積分して積むにあります。使った描画関数は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%膨らみます。









