04二つの角度で描く位相平面の軌跡
第一の角度を横、第二の角度を縦に置いて一歩ごとに点をつなぎ、古い線を薄く消して、振り子の動きを一枚の線画として積み上げる。
使いどころ動きに隠れた形を見るとき
FADE_ALPHAPLANE_SIZEwrapAngleplaneXplaneY

<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 に同梱 · 二重振り子 シミュレーション 9パターン — 誤差ひとつが道を分けるカオス
このデモが属する記事
二重振り子 シミュレーション 9パターン — 誤差ひとつが道を分けるカオス出発角度を百万分の一ラジアン変えるだけで先端はまったく別の道を描く — 03番は二つの振り子を重ねてその分かれ道を目の前で見せる。以下の九つの二重振り子はキャンバス2dでフレームごとに計算して描いたもので、ファイルをダウンロードすればそのま この記事のデモをすべて見る


