06포인터를 쓸어 넘기면 회전이 붙는 팽이
포인터가 팽이 위를 쓸고 지나간 이동 벡터와 중심 벡터의 외적(토크)을 계산해 각속도에 더해, 빠르게 쓸수록 세게 돌고 위아래를 바꿔 쓸면 반대 방향으로 돈다.
쓰임손맛이 있는 인터랙션
SPIN_PER_PXimpartSpinvirtualSwipe

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="포인터가 팽이 위를 지나간 속도의 가로 성분을 읽어 각속도에 더하고 방향도 부호로 정해, 빠르게 쓸수록 세게 돌고 반대로 쓸면 반대 방향으로 돈다."></canvas>
<p class="bp__caption">포인터가 팽이 위를 지나간 속도의 가로 성분을 읽어 각속도에 더하고 방향도 부호로 정해, 빠르게 쓸수록 세게 돌고 반대로 쓸면 반대 방향으로 돈다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
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 { font-size: 10px; padding: $sp-1 $sp-2; }
}
@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%;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
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 {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435006);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
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 + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 포인터가 팽이를 쓸고 지나간 이동 벡터와 팽이 중심에서의 위치 벡터를 외적해 돌림힘을 구하고,
// 그 값에 SPIN_PER_PX 를 곱해 각속도에 더한다. 위를 쓸면 한쪽, 아래를 쓸면 반대쪽으로 돈다.
var SPIN_PER_PX = 0.0045;
var OMEGA_MAX = 44;
var SPIN_FADE = 0.4;
var spinTop = makeTop(160, 160, 2, 42, 7);
var pointerOn = false, pointerX = 0, pointerY = 0, lastPX = 0, lastPY = 0;
var swipeAlpha = 0, trailX = 0;
var charging = false;
var swipes = [
{ off: -34, dir: 1 }, { off: 36, dir: 1 }, { off: 30, dir: -1 }, { off: -38, dir: -1 }
];
var swipeSpan = 1300, swipeLen = 520, demoClock = 0;
function impartSpin(px, py, dx, dy) {
var cx = spinTop.x, cy = spinTop.y - 34;
var rx = px - cx, ry = (py - cy) * 1.4;
if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
var torque = rx * dy - ry * dx;
spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
}
function virtualSwipe() {
var k = Math.floor(demoClock / swipeSpan) % swipes.length;
var local = demoClock % swipeSpan;
if (local >= swipeLen || demoClock < 250) { swipeAlpha = Math.max(0, swipeAlpha - 0.05); return; }
var sw = swipes[k];
var f = local / swipeLen;
var nx = spinTop.x + sw.dir * (-76 + 152 * f);
var ny = spinTop.y - 34 + sw.off;
var nx0 = spinTop.x + sw.dir * (-76 + 152 * clamp((local - FIXED_DT) / swipeLen, 0, 1));
impartSpin(nx, ny, nx - nx0, 0);
pointerX = nx; pointerY = ny; trailX = nx - sw.dir * 34;
swipeAlpha = 1;
}
function stepWorld() {
if (demo) { demoClock += FIXED_DT; virtualSwipe(); }
if (charging) {
var sgn = spinTop.omega < 0 ? -1 : 1;
spinTop.omega = clamp(spinTop.omega + sgn * 45 * dtS, -OMEGA_MAX, OMEGA_MAX);
}
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
spinTop.kick = Math.max(0, spinTop.kick - dtS * 0.8);
var calm = 1 - clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
spinTop.tilt = 0.05 + 0.5 * calm * calm + spinTop.kick * 0.3;
spinTop.az += (2 + calm * 5) * dtS;
stepPulses();
}
function drawSpinRing() {
var frac = clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
var col = spinTop.omega < 0 ? '159,216,255' : '255,179,199';
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
var dirSign = spinTop.omega < 0 ? -1 : 1;
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + dirSign * 6.2832 * frac, dirSign < 0);
ctx.strokeStyle = 'rgba(' + col + ',0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawPointer() {
var live = demo ? swipeAlpha : (pointerOn ? 1 : 0);
if (live <= 0) return;
ctx.beginPath();
ctx.arc(pointerX, pointerY, 10, 0, 6.2832);
ctx.fillStyle = 'rgba(255,255,255,' + (0.28 * live) + ')';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,' + (0.9 * live) + ')';
ctx.lineWidth = 2;
ctx.stroke();
if (demo) {
ctx.beginPath();
ctx.moveTo(trailX, pointerY);
ctx.lineTo(pointerX, pointerY);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.5 * live) + ')';
ctx.lineWidth = 4;
ctx.stroke();
}
}
function drawWorld() {
drawArena();
drawSpinRing();
drawPulses();
drawTop(spinTop);
drawPointer();
}
function toCalc(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
function onPointerDown(e) {
stopDemo();
var p = toCalc(e);
pointerOn = true; pointerX = lastPX = p.x; pointerY = lastPY = p.y;
if (canvas.setPointerCapture) canvas.setPointerCapture(e.pointerId);
if (reduce) drawWorld();
}
function onPointerMove(e) {
if (!pointerOn) return;
var p = toCalc(e);
impartSpin(p.x, p.y, p.x - lastPX, p.y - lastPY);
lastPX = pointerX = p.x; lastPY = pointerY = p.y;
if (reduce) drawWorld();
}
function onPointerUp() { pointerOn = false; if (reduce) drawWorld(); }
function onKeyDown(e) {
var k = e.key;
if (k === ' ' || k === 'Spacebar') {
e.preventDefault(); stopDemo();
if (reduce) { spinTop.omega = clamp(spinTop.omega + 10, -OMEGA_MAX, OMEGA_MAX); drawWorld(); } else { charging = true; }
} else if (k === 'ArrowRight' || k === 'ArrowLeft') {
e.preventDefault(); stopDemo();
spinTop.omega = clamp(spinTop.omega + (k === 'ArrowRight' ? 12 : -12), -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = 0.4;
if (reduce) drawWorld();
}
}
function onKeyUp(e) { if (e.key === ' ' || e.key === 'Spacebar') charging = false; }
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
cleaners.push(function () {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerUp);
canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp);
});
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;
drawWorld();
}
var lastTs = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < 20; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinTopScenePointerSpinImpart.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopScenePointerSpinImpart() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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'); }
function seedRand(seed: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435006);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
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 + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 포인터가 팽이를 쓸고 지나간 이동 벡터와 팽이 중심에서의 위치 벡터를 외적해 돌림힘을 구하고,
// 그 값에 SPIN_PER_PX 를 곱해 각속도에 더한다. 위를 쓸면 한쪽, 아래를 쓸면 반대쪽으로 돈다.
var SPIN_PER_PX = 0.0045;
var OMEGA_MAX = 44;
var SPIN_FADE = 0.4;
var spinTop = makeTop(160, 160, 2, 42, 7);
var pointerOn = false, pointerX = 0, pointerY = 0, lastPX = 0, lastPY = 0;
var swipeAlpha = 0, trailX = 0;
var charging = false;
var swipes = [
{ off: -34, dir: 1 }, { off: 36, dir: 1 }, { off: 30, dir: -1 }, { off: -38, dir: -1 }
];
var swipeSpan = 1300, swipeLen = 520, demoClock = 0;
function impartSpin(px: number, py: number, dx: number, dy: number) {
var cx = spinTop.x, cy = spinTop.y - 34;
var rx = px - cx, ry = (py - cy) * 1.4;
if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
var torque = rx * dy - ry * dx;
spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
}
function virtualSwipe() {
var k = Math.floor(demoClock / swipeSpan) % swipes.length;
var local = demoClock % swipeSpan;
if (local >= swipeLen || demoClock < 250) { swipeAlpha = Math.max(0, swipeAlpha - 0.05); return; }
var sw = swipes[k];
var f = local / swipeLen;
var nx = spinTop.x + sw.dir * (-76 + 152 * f);
var ny = spinTop.y - 34 + sw.off;
var nx0 = spinTop.x + sw.dir * (-76 + 152 * clamp((local - FIXED_DT) / swipeLen, 0, 1));
impartSpin(nx, ny, nx - nx0, 0);
pointerX = nx; pointerY = ny; trailX = nx - sw.dir * 34;
swipeAlpha = 1;
}
function stepWorld() {
if (demo) { demoClock += FIXED_DT; virtualSwipe(); }
if (charging) {
var sgn = spinTop.omega < 0 ? -1 : 1;
spinTop.omega = clamp(spinTop.omega + sgn * 45 * dtS, -OMEGA_MAX, OMEGA_MAX);
}
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
spinTop.kick = Math.max(0, spinTop.kick - dtS * 0.8);
var calm = 1 - clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
spinTop.tilt = 0.05 + 0.5 * calm * calm + spinTop.kick * 0.3;
spinTop.az += (2 + calm * 5) * dtS;
stepPulses();
}
function drawSpinRing() {
var frac = clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
var col = spinTop.omega < 0 ? '159,216,255' : '255,179,199';
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
var dirSign = spinTop.omega < 0 ? -1 : 1;
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + dirSign * 6.2832 * frac, dirSign < 0);
ctx.strokeStyle = 'rgba(' + col + ',0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawPointer() {
var live = demo ? swipeAlpha : (pointerOn ? 1 : 0);
if (live <= 0) return;
ctx.beginPath();
ctx.arc(pointerX, pointerY, 10, 0, 6.2832);
ctx.fillStyle = 'rgba(255,255,255,' + (0.28 * live) + ')';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,' + (0.9 * live) + ')';
ctx.lineWidth = 2;
ctx.stroke();
if (demo) {
ctx.beginPath();
ctx.moveTo(trailX, pointerY);
ctx.lineTo(pointerX, pointerY);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.5 * live) + ')';
ctx.lineWidth = 4;
ctx.stroke();
}
}
function drawWorld() {
drawArena();
drawSpinRing();
drawPulses();
drawTop(spinTop);
drawPointer();
}
function toCalc(e: PointerEvent) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
function onPointerDown(e: PointerEvent) {
stopDemo();
var p = toCalc(e);
pointerOn = true; pointerX = lastPX = p.x; pointerY = lastPY = p.y;
if (canvas.setPointerCapture) canvas.setPointerCapture(e.pointerId);
if (reduce) drawWorld();
}
function onPointerMove(e: PointerEvent) {
if (!pointerOn) return;
var p = toCalc(e);
impartSpin(p.x, p.y, p.x - lastPX, p.y - lastPY);
lastPX = pointerX = p.x; lastPY = pointerY = p.y;
if (reduce) drawWorld();
}
function onPointerUp() { pointerOn = false; if (reduce) drawWorld(); }
function onKeyDown(e: KeyboardEvent) {
var k = e.key;
if (k === ' ' || k === 'Spacebar') {
e.preventDefault(); stopDemo();
if (reduce) { spinTop.omega = clamp(spinTop.omega + 10, -OMEGA_MAX, OMEGA_MAX); drawWorld(); } else { charging = true; }
} else if (k === 'ArrowRight' || k === 'ArrowLeft') {
e.preventDefault(); stopDemo();
spinTop.omega = clamp(spinTop.omega + (k === 'ArrowRight' ? 12 : -12), -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = 0.4;
if (reduce) drawWorld();
}
}
function onKeyUp(e: KeyboardEvent) { if (e.key === ' ' || e.key === 'Spacebar') charging = false; }
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
cleaners.push(function () {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerUp);
canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp);
});
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;
drawWorld();
}
var lastTs: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < 20; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="포인터가 팽이 위를 지나간 속도의 가로 성분을 읽어 각속도에 더하고 방향도 부호로 정해, 빠르게 쓸수록 세게 돌고 반대로 쓸면 반대 방향으로 돈다." />
<p className={styles.bp__caption}>포인터가 팽이 위를 지나간 속도의 가로 성분을 읽어 각속도에 더하고 방향도 부호로 정해, 빠르게 쓸수록 세게 돌고 반대로 쓸면 반대 방향으로 돈다.</p>
</div>
</main>
);
}06 포인터를 쓸어 넘기면 회전이 붙는 팽이 — 측정 누적면적 15.637% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면
이 데모가 속한 글
팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면돌리고, 기울고, 비틀거리고, 눕는다 — 팽이의 일생은 네 동사로 끝나고, 03번은 그 두 번째 동사를 숫자로 계산한다. 아래 아홉 개는 팽이 돌리기를 캔버스 2d 위에서 프레임마다 다시 계산해 그린 장면이며, 파일 이 글의 데모 전부 보기


