02Letters Swinging on Hanging Ropes
A rope hanging from above is split into ROPE_SEGMENTS points moved by position integration that carries speed from the previous position, segment lengths are restored CONSTRAINT_ITERATIONS times, AIR_DAMPING calms the swing, and each letter tilts with its last segment.
Best forHanging sign on an event banner
ROPE_SEGMENTSCONSTRAINT_ITERATIONSAIR_DAMPING

<main class="stage">
<div class="pc is-demo" id="pc">
<p class="pc__sr" data-role="word">HANG TIGHT</p>
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="Letter signs hanging on ropes swing after being lifted and let go. Drag to push the ropes aside, use the side arrows to shake them, press Enter to lift them again and Space to pause."></canvas>
<p class="pc__status" data-role="status" aria-live="off">Ropes swinging</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">Lift again</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
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: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 원문 한 줄 — 화면에는 숨기고 화면 낭독기에만 남긴다(낱글자는 캔버스 그림이라 따로 읽히지 않는다)
.pc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 무대를 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
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;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}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);
}
.pc {
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;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
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;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 바닥 높이 — 왼쪽 아래 상태 줄 위에서 글자가 멈추도록 바닥을 띄워 둔다
const FLOOR = 250;
// 다시 떨어뜨린 뒤 첫 200ms 는 그대로 둔다 — 첫 변화가 200ms 뒤에 오게
const LEAD_MS = 200;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 글자를 얹어 명도 차로 글자 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const FLOOR_COL = [56, 50, 78];
const INK = [33, 29, 46];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const wordEl = root.querySelector('[data-role="word"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !wordEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
// 원문 한 줄 — 화면 낭독기는 이 문장만 한 번 읽는다. 캔버스에 그린 낱글자는 접근성 트리에 들어가지 않는다
const WORD = (wordEl.textContent || '').trim();
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 그때그때 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
// 글자 사이 간격 — 글자 크기에 대한 비율
const GLYPH_GAP = 0.1;
function fontAt(px) { return '800 ' + px.toFixed(1) + 'px ' + FONT_FAMILY; }
function layoutWord(text, sizeMax, maxW, cx, cy) {
const chars = Array.from(text);
ctx.font = fontAt(sizeMax);
const ws = [];
let total = 0;
for (let i = 0; i < chars.length; i += 1) {
const m = chars[i].trim() === '' ? sizeMax * 0.3 : ctx.measureText(chars[i]).width;
ws.push(m);
total += m;
}
total += GLYPH_GAP * sizeMax * Math.max(0, chars.length - 1);
// 줄이 maxW 보다 길면 글자 크기를 같은 비율로 줄인다 — 언어마다 글자 수가 달라도 무대 안에 든다
const k = total > maxW ? maxW / total : 1;
const size = sizeMax * k;
const out = [];
let x = cx - (total * k) / 2;
let n = 0;
for (let i = 0; i < chars.length; i += 1) {
const w = ws[i] * k;
if (chars[i].trim() !== '') {
out.push({ ch: chars[i], w: Math.max(w, size * 0.34), h: size * 0.9, hx: x + w / 2, hy: cy, size: size, col: PAL[n % PAL.length], i: n });
n += 1;
}
x += w + GLYPH_GAP * size;
}
return out;
}
// 글자 하나 그리기 — 같은 글자를 어둡게 한 번 아래로 비켜 찍어 두께를 만든 뒤 제 색으로 덮는다
function drawGlyph(g, x, y, a, sx, sy, fill) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.scale(sx, sy);
ctx.font = fontAt(g.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(shadeRGB(fill, 0.5), 1);
ctx.fillText(g.ch, 1.4, g.size * 0.04 + 2.8);
ctx.fillStyle = rgba(fill, 1);
ctx.fillText(g.ch, 0, g.size * 0.04);
ctx.restore();
}
// 모서리가 둥근 사각형 경로 — 가운데 (0,0) 기준 폭 w·높이 h
function roundRectPath(x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
}
// 바닥 그림자 — 글자 아래끝이 바닥에서 멀수록 옅고 넓게. 높이 값 하나로 크기와 진하기를 함께 정한다
function floorShadow(x, bottom, w) {
const lift = clamp((FLOOR - bottom) / 160, 0, 1);
ctx.fillStyle = rgba([0, 0, 0], 0.34 * (1 - lift) + 0.05);
ctx.beginPath();
ctx.ellipse(x, FLOOR + 3, w * (0.5 + 0.4 * lift), 3.5 + 2.5 * lift, 0, 0, TAU);
ctx.fill();
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawFloor() {
ctx.fillStyle = rgba(FLOOR_COL, 1);
ctx.fillRect(0, FLOOR, W, H - FLOOR);
ctx.fillStyle = rgba(PAPER, 0.22);
ctx.fillRect(0, FLOOR, W, 1.5);
}
const TXT_PAUSE = 'Paused — press Space to resume';
// 시계 — clock 은 시연 시계, age 는 다시 떨어뜨린 뒤 흐른 시간(ms). 둘 다 16ms 고정 스텝으로만 늘어난다
let paused = false;
let clock = 0;
let age = 0;
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
age = 0;
onRestart();
}
function tick() {
age += FIXED_DT;
step();
}
function report() { say(paused ? TXT_PAUSE : stateText()); }
function render() {
draw();
report();
}
// 시연 — DEMO_LOOP_MS 마다 처음부터 다시 떨어뜨린다
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP_MS);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
demoDrive(t - cyc * DEMO_LOOP_MS);
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 내려앉은 결과만 그린다
const SETTLE_TICKS = 360;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
const p = toScene(ev);
onPointer('down', p[0], p[1]);
after();
});
cv.addEventListener('pointermove', function (ev) {
const p = toScene(ev);
if (onPointer('move', p[0], p[1])) {
wake();
after();
}
});
cv.addEventListener('pointerup', function (ev) {
const p = toScene(ev);
onPointer('up', p[0], p[1]);
});
cv.addEventListener('pointercancel', function () { onPointer('up', -1, -1); });
cv.addEventListener('pointerleave', function () {
if (onPointer('leave', -1, -1)) {
after();
}
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (!onKey(k)) {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 줄 하나를 나누는 마디 수 — 점은 마디 수 + 1 개(맨 위 점은 걸이에 고정)
const ROPE_SEGMENTS = 6;
// 한 틱에 마디 길이를 되맞추는 횟수 — 적으면 줄이 고무줄처럼 늘어난다
const CONSTRAINT_ITERATIONS = 8;
// 공기 저항 — 이전 위치와의 차(속도)에서 틱마다 이 비율만큼 덜어 낸다
const AIR_DAMPING = 0.012;
const ROPE_GRAVITY = 0.32;
// 줄 길이 — 왼쪽부터 조금씩 길어진다. 길이가 다르면 흔들리는 주기가 달라 물결처럼 어긋난다
const ROPE_LEN_MIN = 46;
const ROPE_LEN_STEP = 10;
// 놓기 전 자세 — 줄을 곧게 펴 이 각도만큼 오른쪽으로 들어 올린다
const RELEASE_ANGLE = 30 * DEG;
// 끝 점(글자)의 질량 역수 — 줄 마디(1)보다 무거워 마디를 맞출 때 덜 끌려간다
const LETTER_INV_MASS = 0.3;
const RAIL_Y = 16;
// 손가락·포인터가 줄을 밀어내는 반지름(px)
const PUSH_R = 22;
const DEMO_LOOP_MS = 3400;
const TXT_RUN = 'Ropes swinging';
const TXT_REST = 'The swing has settled — press Enter to lift again';
const GLYPHS = layoutWord(WORD, 44, 400, 240, 0);
let ropes = [];
let hand = { on: false, x: 0, y: 0 };
function onRestart() {
ropes = GLYPHS.map(function (g, i) {
const rest = (ROPE_LEN_MIN + i * ROPE_LEN_STEP) / ROPE_SEGMENTS;
const r = { g: g, ax: g.hx, px: [], py: [], ox: [], oy: [], rest: rest };
for (let k = 0; k <= ROPE_SEGMENTS; k += 1) {
const x = g.hx + Math.sin(RELEASE_ANGLE) * rest * k;
const y = RAIL_Y + Math.cos(RELEASE_ANGLE) * rest * k;
r.px.push(x);
r.py.push(y);
r.ox.push(x);
r.oy.push(y);
}
return r;
});
}
function invMass(k) {
if (k === 0) {
return 0;
}
return k === ROPE_SEGMENTS ? LETTER_INV_MASS : 1;
}
// 마디 하나의 길이를 rest 로 되돌린다 — 두 점을 질량 역수 비율로 나눠 당기거나 민다
function fixSegment(r, k) {
const dx = r.px[k + 1] - r.px[k];
const dy = r.py[k + 1] - r.py[k];
const d = Math.max(Math.hypot(dx, dy), 0.0001);
const wa = invMass(k);
const wb = invMass(k + 1);
const diff = (d - r.rest) / d / (wa + wb);
r.px[k] += dx * diff * wa;
r.py[k] += dy * diff * wa;
r.px[k + 1] -= dx * diff * wb;
r.py[k + 1] -= dy * diff * wb;
}
// 이웃 글자끼리 겹치면 옆으로 나눠 민다 — 매달린 간판끼리 부딪히는 자리
function separateNeighbors() {
for (let i = 0; i + 1 < ropes.length; i += 1) {
const a = ropes[i];
const b = ropes[i + 1];
const n = ROPE_SEGMENTS;
const minD = (a.g.w + b.g.w) / 2 + 6;
const dx = b.px[n] - a.px[n];
if (Math.abs(b.py[n] - a.py[n]) > (a.g.h + b.g.h) / 2 || Math.abs(dx) >= minD) {
continue;
}
const push = ((minD - Math.abs(dx)) / 2) * (dx < 0 ? -1 : 1);
a.px[n] -= push;
b.px[n] += push;
}
}
function step() {
if (age < LEAD_MS) {
return;
}
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
// 위치 적분 — 이전 위치와의 차를 속도로 이어 받고, 공기 저항만큼 덜어 낸 뒤 중력을 더한다
for (let k = 1; k <= ROPE_SEGMENTS; k += 1) {
const vx = (r.px[k] - r.ox[k]) * (1 - AIR_DAMPING);
const vy = (r.py[k] - r.oy[k]) * (1 - AIR_DAMPING);
r.ox[k] = r.px[k];
r.oy[k] = r.py[k];
r.px[k] += vx;
r.py[k] += vy + ROPE_GRAVITY;
}
}
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
r.px[0] = r.ax;
r.py[0] = RAIL_Y;
for (let k = 0; k < ROPE_SEGMENTS; k += 1) {
fixSegment(r, k);
}
if (hand.on) {
for (let k = 1; k <= ROPE_SEGMENTS; k += 1) {
const dx = r.px[k] - hand.x;
const dy = r.py[k] - hand.y;
const d = Math.hypot(dx, dy);
if (d < PUSH_R && d > 0.001) {
r.px[k] = hand.x + (dx / d) * PUSH_R;
r.py[k] = hand.y + (dy / d) * PUSH_R;
}
}
}
}
separateNeighbors();
}
}
// 글자 Tilt — 마지막 마디 방향을 글자의 아래쪽으로 삼는다
function tiltOf(r) {
const n = ROPE_SEGMENTS;
return Math.atan2(-(r.px[n] - r.px[n - 1]), r.py[n] - r.py[n - 1]);
}
function swingSpeed() {
let m = 0;
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
const n = ROPE_SEGMENTS;
m = Math.max(m, Math.hypot(r.px[n] - r.ox[n], r.py[n] - r.oy[n]));
}
return m;
}
function stateText() { return still || (age > LEAD_MS && swingSpeed() < 0.05) ? TXT_REST : TXT_RUN; }
function draw() {
clearStage();
ctx.fillStyle = rgba(FLOOR_COL, 1);
ctx.fillRect(0, RAIL_Y - 5, W, 5);
ctx.fillStyle = rgba(PAPER, 0.25);
ctx.fillRect(0, RAIL_Y, W, 1);
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
ctx.strokeStyle = rgba(PAPER, 0.55);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(r.px[0], r.py[0]);
for (let k = 1; k <= ROPE_SEGMENTS; k += 1) {
ctx.lineTo(r.px[k], r.py[k]);
}
ctx.stroke();
// 마디 점 — ROPE_SEGMENTS 를 바꾸면 점 수가 바로 보인다
ctx.fillStyle = rgba(r.g.col, 0.9);
for (let k = 1; k < ROPE_SEGMENTS; k += 1) {
ctx.beginPath();
ctx.arc(r.px[k], r.py[k], 1.8, 0, TAU);
ctx.fill();
}
}
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
const n = ROPE_SEGMENTS;
const g = r.g;
const tw = g.w + 14;
const th = g.h + 12;
ctx.save();
ctx.translate(r.px[n], r.py[n]);
ctx.rotate(tiltOf(r));
// 간판 그림자 — 기운 만큼 옆으로 비켜 떨어진다
ctx.fillStyle = rgba([0, 0, 0], 0.28);
roundRectPath(-tw / 2 + 3, 5, tw, th, 7);
ctx.fill();
ctx.fillStyle = rgba(g.col, 1);
roundRectPath(-tw / 2, 2, tw, th, 7);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(0, 6, 2.2, 0, TAU);
ctx.fill();
ctx.restore();
drawTagLetter(r, th);
}
if (hand.on) {
ctx.strokeStyle = rgba(PAPER, 0.5);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(hand.x, hand.y, PUSH_R, 0, TAU);
ctx.stroke();
}
}
function drawTagLetter(r, th) {
const n = ROPE_SEGMENTS;
const a = tiltOf(r);
const cx = r.px[n] - Math.sin(a) * (2 + th / 2);
const cy = r.py[n] + Math.cos(a) * (2 + th / 2);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(a);
ctx.font = fontAt(r.g.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(INK, 1);
ctx.fillText(r.g.ch, 0, r.g.size * 0.04);
ctx.restore();
}
function demoDrive(t) { }
let dragging = false;
function onPointer(type, x, y) {
if (type === 'down') {
dragging = true;
hand = { on: true, x: x, y: y };
return false;
}
if (type === 'move' && dragging) {
hand = { on: true, x: x, y: y };
return true;
}
if (type === 'up' || type === 'leave') {
dragging = false;
hand = { on: false, x: 0, y: 0 };
}
return false;
}
// 방향키 — 글자(끝 점)의 이전 위치를 반대로 옮겨 그만큼 옆 속도를 준다
function onKey(k) {
if (k !== 'ArrowLeft' && k !== 'ArrowRight') {
return false;
}
const s = k === 'ArrowLeft' ? -1 : 1;
for (let i = 0; i < ropes.length; i += 1) {
ropes[i].ox[ROPE_SEGMENTS] -= 7 * s;
}
if (age < LEAD_MS) {
age = LEAD_MS;
}
return true;
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
if (!paused) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
tick();
}
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 내려앉은 결과로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
restart();
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// RopeHungSwing - vanilla 판(02-tx-phys-rope-hung-swing) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RopeHungSwing.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RopeHungSwing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 바닥 높이 — 왼쪽 아래 상태 줄 위에서 글자가 멈추도록 바닥을 띄워 둔다
const FLOOR = 250;
// 다시 떨어뜨린 뒤 첫 200ms 는 그대로 둔다 — 첫 변화가 200ms 뒤에 오게
const LEAD_MS = 200;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 글자를 얹어 명도 차로 글자 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const FLOOR_COL: RGB = [56, 50, 78];
const INK: RGB = [33, 29, 46];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const wordEl = root.querySelector('[data-role="word"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !wordEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
// 원문 한 줄 — 화면 낭독기는 이 문장만 한 번 읽는다. 캔버스에 그린 낱글자는 접근성 트리에 들어가지 않는다
const WORD = (wordEl.textContent || '').trim();
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 그때그때 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 글자 상자 — measureText 로 잰 폭과 글자 크기로 상자를 잡고, 원래 읽는 순서대로 자리(hx, hy)를 매긴다
type Glyph = { ch: string; w: number; h: number; hx: number; hy: number; size: number; col: RGB; i: number };
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
// 글자 사이 간격 — 글자 크기에 대한 비율
const GLYPH_GAP = 0.1;
function fontAt(px: number): string { return '800 ' + px.toFixed(1) + 'px ' + FONT_FAMILY; }
function layoutWord(text: string, sizeMax: number, maxW: number, cx: number, cy: number): Glyph[] {
const chars = Array.from(text);
ctx.font = fontAt(sizeMax);
const ws: number[] = [];
let total = 0;
for (let i = 0; i < chars.length; i += 1) {
const m = chars[i].trim() === '' ? sizeMax * 0.3 : ctx.measureText(chars[i]).width;
ws.push(m);
total += m;
}
total += GLYPH_GAP * sizeMax * Math.max(0, chars.length - 1);
// 줄이 maxW 보다 길면 글자 크기를 같은 비율로 줄인다 — 언어마다 글자 수가 달라도 무대 안에 든다
const k = total > maxW ? maxW / total : 1;
const size = sizeMax * k;
const out: Glyph[] = [];
let x = cx - (total * k) / 2;
let n = 0;
for (let i = 0; i < chars.length; i += 1) {
const w = ws[i] * k;
if (chars[i].trim() !== '') {
out.push({ ch: chars[i], w: Math.max(w, size * 0.34), h: size * 0.9, hx: x + w / 2, hy: cy, size: size, col: PAL[n % PAL.length], i: n });
n += 1;
}
x += w + GLYPH_GAP * size;
}
return out;
}
// 글자 하나 그리기 — 같은 글자를 어둡게 한 번 아래로 비켜 찍어 두께를 만든 뒤 제 색으로 덮는다
function drawGlyph(g: Glyph, x: number, y: number, a: number, sx: number, sy: number, fill: RGB): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.scale(sx, sy);
ctx.font = fontAt(g.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(shadeRGB(fill, 0.5), 1);
ctx.fillText(g.ch, 1.4, g.size * 0.04 + 2.8);
ctx.fillStyle = rgba(fill, 1);
ctx.fillText(g.ch, 0, g.size * 0.04);
ctx.restore();
}
// 모서리가 둥근 사각형 경로 — 가운데 (0,0) 기준 폭 w·높이 h
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
}
// 바닥 그림자 — 글자 아래끝이 바닥에서 멀수록 옅고 넓게. 높이 값 하나로 크기와 진하기를 함께 정한다
function floorShadow(x: number, bottom: number, w: number): void {
const lift = clamp((FLOOR - bottom) / 160, 0, 1);
ctx.fillStyle = rgba([0, 0, 0], 0.34 * (1 - lift) + 0.05);
ctx.beginPath();
ctx.ellipse(x, FLOOR + 3, w * (0.5 + 0.4 * lift), 3.5 + 2.5 * lift, 0, 0, TAU);
ctx.fill();
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawFloor(): void {
ctx.fillStyle = rgba(FLOOR_COL, 1);
ctx.fillRect(0, FLOOR, W, H - FLOOR);
ctx.fillStyle = rgba(PAPER, 0.22);
ctx.fillRect(0, FLOOR, W, 1.5);
}
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시계 — clock 은 시연 시계, age 는 다시 떨어뜨린 뒤 흐른 시간(ms). 둘 다 16ms 고정 스텝으로만 늘어난다
let paused = false;
let clock = 0;
let age = 0;
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
age = 0;
onRestart();
}
function tick(): void {
age += FIXED_DT;
step();
}
function report(): void { say(paused ? TXT_PAUSE : stateText()); }
function render(): void {
draw();
report();
}
// 시연 — DEMO_LOOP_MS 마다 처음부터 다시 떨어뜨린다
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP_MS);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
demoDrive(t - cyc * DEMO_LOOP_MS);
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 내려앉은 결과만 그린다
const SETTLE_TICKS = 360;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
const p = toScene(ev);
onPointer('down', p[0], p[1]);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toScene(ev);
if (onPointer('move', p[0], p[1])) {
wake();
after();
}
});
cv.addEventListener('pointerup', function (ev: PointerEvent): void {
const p = toScene(ev);
onPointer('up', p[0], p[1]);
});
cv.addEventListener('pointercancel', function (): void { onPointer('up', -1, -1); });
cv.addEventListener('pointerleave', function (): void {
if (onPointer('leave', -1, -1)) { after(); }
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (!onKey(k)) { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 줄 하나를 나누는 마디 수 — 점은 마디 수 + 1 개(맨 위 점은 걸이에 고정)
const ROPE_SEGMENTS = 6;
// 한 틱에 마디 길이를 되맞추는 횟수 — 적으면 줄이 고무줄처럼 늘어난다
const CONSTRAINT_ITERATIONS = 8;
// 공기 저항 — 이전 위치와의 차(속도)에서 틱마다 이 비율만큼 덜어 낸다
const AIR_DAMPING = 0.012;
const ROPE_GRAVITY = 0.32;
// 줄 길이 — 왼쪽부터 조금씩 길어진다. 길이가 다르면 흔들리는 주기가 달라 물결처럼 어긋난다
const ROPE_LEN_MIN = 46;
const ROPE_LEN_STEP = 10;
// 놓기 전 자세 — 줄을 곧게 펴 이 각도만큼 오른쪽으로 들어 올린다
const RELEASE_ANGLE = 30 * DEG;
// 끝 점(글자)의 질량 역수 — 줄 마디(1)보다 무거워 마디를 맞출 때 덜 끌려간다
const LETTER_INV_MASS = 0.3;
const RAIL_Y = 16;
// 손가락·포인터가 줄을 밀어내는 반지름(px)
const PUSH_R = 22;
const DEMO_LOOP_MS = 3400;
const TXT_RUN = '줄이 흔들리는 중';
const TXT_REST = '흔들림이 잦아들었어요 — Enter 로 다시';
const GLYPHS: Glyph[] = layoutWord(WORD, 44, 400, 240, 0);
type Rope = { g: Glyph; ax: number; px: number[]; py: number[]; ox: number[]; oy: number[]; rest: number };
let ropes: Rope[] = [];
let hand = { on: false, x: 0, y: 0 };
function onRestart(): void {
ropes = GLYPHS.map(function (g: Glyph, i: number): Rope {
const rest = (ROPE_LEN_MIN + i * ROPE_LEN_STEP) / ROPE_SEGMENTS;
const r: Rope = { g: g, ax: g.hx, px: [], py: [], ox: [], oy: [], rest: rest };
for (let k = 0; k <= ROPE_SEGMENTS; k += 1) {
const x = g.hx + Math.sin(RELEASE_ANGLE) * rest * k;
const y = RAIL_Y + Math.cos(RELEASE_ANGLE) * rest * k;
r.px.push(x);
r.py.push(y);
r.ox.push(x);
r.oy.push(y);
}
return r;
});
}
function invMass(k: number): number {
if (k === 0) { return 0; }
return k === ROPE_SEGMENTS ? LETTER_INV_MASS : 1;
}
// 마디 하나의 길이를 rest 로 되돌린다 — 두 점을 질량 역수 비율로 나눠 당기거나 민다
function fixSegment(r: Rope, k: number): void {
const dx = r.px[k + 1] - r.px[k];
const dy = r.py[k + 1] - r.py[k];
const d = Math.max(Math.hypot(dx, dy), 0.0001);
const wa = invMass(k);
const wb = invMass(k + 1);
const diff = (d - r.rest) / d / (wa + wb);
r.px[k] += dx * diff * wa;
r.py[k] += dy * diff * wa;
r.px[k + 1] -= dx * diff * wb;
r.py[k + 1] -= dy * diff * wb;
}
// 이웃 글자끼리 겹치면 옆으로 나눠 민다 — 매달린 간판끼리 부딪히는 자리
function separateNeighbors(): void {
for (let i = 0; i + 1 < ropes.length; i += 1) {
const a = ropes[i];
const b = ropes[i + 1];
const n = ROPE_SEGMENTS;
const minD = (a.g.w + b.g.w) / 2 + 6;
const dx = b.px[n] - a.px[n];
if (Math.abs(b.py[n] - a.py[n]) > (a.g.h + b.g.h) / 2 || Math.abs(dx) >= minD) { continue; }
const push = ((minD - Math.abs(dx)) / 2) * (dx < 0 ? -1 : 1);
a.px[n] -= push;
b.px[n] += push;
}
}
function step(): void {
if (age < LEAD_MS) { return; }
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
// 위치 적분 — 이전 위치와의 차를 속도로 이어 받고, 공기 저항만큼 덜어 낸 뒤 중력을 더한다
for (let k = 1; k <= ROPE_SEGMENTS; k += 1) {
const vx = (r.px[k] - r.ox[k]) * (1 - AIR_DAMPING);
const vy = (r.py[k] - r.oy[k]) * (1 - AIR_DAMPING);
r.ox[k] = r.px[k];
r.oy[k] = r.py[k];
r.px[k] += vx;
r.py[k] += vy + ROPE_GRAVITY;
}
}
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
r.px[0] = r.ax;
r.py[0] = RAIL_Y;
for (let k = 0; k < ROPE_SEGMENTS; k += 1) { fixSegment(r, k); }
if (hand.on) {
for (let k = 1; k <= ROPE_SEGMENTS; k += 1) {
const dx = r.px[k] - hand.x;
const dy = r.py[k] - hand.y;
const d = Math.hypot(dx, dy);
if (d < PUSH_R && d > 0.001) {
r.px[k] = hand.x + (dx / d) * PUSH_R;
r.py[k] = hand.y + (dy / d) * PUSH_R;
}
}
}
}
separateNeighbors();
}
}
// 글자 기울기 — 마지막 마디 방향을 글자의 아래쪽으로 삼는다
function tiltOf(r: Rope): number {
const n = ROPE_SEGMENTS;
return Math.atan2(-(r.px[n] - r.px[n - 1]), r.py[n] - r.py[n - 1]);
}
function swingSpeed(): number {
let m = 0;
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
const n = ROPE_SEGMENTS;
m = Math.max(m, Math.hypot(r.px[n] - r.ox[n], r.py[n] - r.oy[n]));
}
return m;
}
function stateText(): string { return still || (age > LEAD_MS && swingSpeed() < 0.05) ? TXT_REST : TXT_RUN; }
function draw(): void {
clearStage();
ctx.fillStyle = rgba(FLOOR_COL, 1);
ctx.fillRect(0, RAIL_Y - 5, W, 5);
ctx.fillStyle = rgba(PAPER, 0.25);
ctx.fillRect(0, RAIL_Y, W, 1);
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
ctx.strokeStyle = rgba(PAPER, 0.55);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(r.px[0], r.py[0]);
for (let k = 1; k <= ROPE_SEGMENTS; k += 1) { ctx.lineTo(r.px[k], r.py[k]); }
ctx.stroke();
// 마디 점 — ROPE_SEGMENTS 를 바꾸면 점 수가 바로 보인다
ctx.fillStyle = rgba(r.g.col, 0.9);
for (let k = 1; k < ROPE_SEGMENTS; k += 1) {
ctx.beginPath();
ctx.arc(r.px[k], r.py[k], 1.8, 0, TAU);
ctx.fill();
}
}
for (let i = 0; i < ropes.length; i += 1) {
const r = ropes[i];
const n = ROPE_SEGMENTS;
const g = r.g;
const tw = g.w + 14;
const th = g.h + 12;
ctx.save();
ctx.translate(r.px[n], r.py[n]);
ctx.rotate(tiltOf(r));
// 간판 그림자 — 기운 만큼 옆으로 비켜 떨어진다
ctx.fillStyle = rgba([0, 0, 0], 0.28);
roundRectPath(-tw / 2 + 3, 5, tw, th, 7);
ctx.fill();
ctx.fillStyle = rgba(g.col, 1);
roundRectPath(-tw / 2, 2, tw, th, 7);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(0, 6, 2.2, 0, TAU);
ctx.fill();
ctx.restore();
drawTagLetter(r, th);
}
if (hand.on) {
ctx.strokeStyle = rgba(PAPER, 0.5);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(hand.x, hand.y, PUSH_R, 0, TAU);
ctx.stroke();
}
}
function drawTagLetter(r: Rope, th: number): void {
const n = ROPE_SEGMENTS;
const a = tiltOf(r);
const cx = r.px[n] - Math.sin(a) * (2 + th / 2);
const cy = r.py[n] + Math.cos(a) * (2 + th / 2);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(a);
ctx.font = fontAt(r.g.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(INK, 1);
ctx.fillText(r.g.ch, 0, r.g.size * 0.04);
ctx.restore();
}
function demoDrive(t: number): void { /* 시연은 들어 올린 줄을 놓기만 한다 */ }
let dragging = false;
function onPointer(type: string, x: number, y: number): boolean {
if (type === 'down') { dragging = true; hand = { on: true, x: x, y: y }; return false; }
if (type === 'move' && dragging) { hand = { on: true, x: x, y: y }; return true; }
if (type === 'up' || type === 'leave') { dragging = false; hand = { on: false, x: 0, y: 0 }; }
return false;
}
// 방향키 — 글자(끝 점)의 이전 위치를 반대로 옮겨 그만큼 옆 속도를 준다
function onKey(k: string): boolean {
if (k !== 'ArrowLeft' && k !== 'ArrowRight') { return false; }
const s = k === 'ArrowLeft' ? -1 : 1;
for (let i = 0; i < ropes.length; i += 1) { ropes[i].ox[ROPE_SEGMENTS] -= 7 * s; }
if (age < LEAD_MS) { age = LEAD_MS; }
return true;
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
if (!paused) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
tick();
}
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 내려앉은 결과로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
restart();
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<p className={cx("pc__sr")} data-role="word">매달린 간판</p>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="줄에 매달린 글자 간판들이 들어 올렸다 놓인 뒤 흔들리는 장면. 끌면 줄을 밀어내고, 옆 방향키로 흔들고, Enter 로 다시 들어 올리고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">줄이 흔들리는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 들어 올리기</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}02 Letters Swinging on Hanging Ropes — Measured area 16.641% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Falling Text Animation: 9 Letters With Physics
The post this demo lives in
Falling Text Animation: 9 Letters With PhysicsDrop a headline and every letter bounces alike. Here 01 spins corner landings into a pile, 03 squashes letters like jell See all demos in this post


