07Pendulum Whose Start Angles You Set by Dragging
Drag either arm with the pointer or arrow keys to any angle and the calculation pauses, then restarts from that angle at zero speed the moment you let go.
Best forA hands-on experiment screen
GRAB_RADIUSKEY_ANGLE_STEPPATH_STEPSDEMO_CYCLE_MSdragTo

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="Drag either arm with the pointer or arrow keys to any angle and the calculation pauses, then restarts from that angle at zero speed the moment you let go."></canvas>
<p class="bp__status" id="bpStatus" aria-live="polite">Demo running</p>
<button class="bp__btn" id="bpBtn" type="button">Start pose</button>
<p class="bp__caption">Drag either arm with the pointer or arrow keys to any angle and the calculation pauses, then restarts from that angle at zero speed the moment you let go.</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('Stepping one scene at a time');
return;
}
paused = !paused;
btnEl.textContent = paused ? 'Play' : 'Pause';
setStatus(paused ? 'Paused' : 'Demo running');
}
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('Drew the path the tip will take after release');
}
else {
setStatus('Released: starting from this angle');
}
}
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('Dragging to set the angle');
}
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('Released: starting from this angle');
}
}
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('Dragging to set the angle');
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('Arrow keys adjust the angle; Space releases');
draw();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
stopDemo();
if (held) {
releaseArm();
}
else {
held = true;
sim[2] = 0;
sim[3] = 0;
setStatus('Arrow keys adjust the angle; Space releases');
}
draw();
}
});
onButton = function () {
stopDemo();
setPose(START_T1, START_T2);
held = true;
releaseArm();
if (reduce) {
held = true;
}
draw();
};
if (reduce) {
makePreview();
setStatus('Dragging to set the angle');
}
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 = 'Next scene';
setStatus('Stepping one scene at a time');
}
}
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 Pendulum Whose Start Angles You Set by Dragging — Measured area 61.799% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Double Pendulum Simulation: 9 Chaos Scenes
The post this demo lives in
Double Pendulum Simulation: 9 Chaos ScenesChange the start angle by one millionth of a radian and the tip draws a completely different path — scene 03 stacks two See all demos in this post
If you like this effect - similar demos
game07 The entry editor — a wheel whose labels and angles recompute togetherSpin Wheel: 9 Canvas Roulettes, Flick to Stop
game07 A Pointer Drag That Snaps Into a Cell on ReleaseSliding Puzzle: 9 Canvas Scenes, Parity to Search
game01 Paddle That Trails the Dragged PositionBrick Breaker Game JavaScript — 9 Physics Patterns