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


