03Friction decay — a disc that integrates each frame to a stop
In the rAF loop, measure dt, integrate angle += angularVelocity·dt and angularVelocity *= friction^dt — fast right after the flick, slowing on an exponential curve. When velocity drops under a threshold the loop ends and the resting angle is fixed.
Best forPhysics-feel interactions, games
requestAnimationFrameperformance.now

<main class="stage">
<div class="fr" id="fr">
<p class="fr__hint">The rAF loop measures dt and integrates angle += angVel·dt, angVel ×= friction^dt</p>
<div class="fr__row">
<div class="fr__field" id="field" role="application" aria-label="Friction decay lab" tabindex="0">
<canvas class="fr__cv" id="cv"></canvas>
</div>
<div class="fr__side">
<b class="fr__num" id="coef">0.30</b>
<span class="fr__unit">Friction coefficient — multiplied into velocity each second</span>
<div class="fr__sw" id="sw" role="group" aria-label="Pick a friction coefficient"></div>
<p class="fr__meta">Time to rest <b class="fr__count" id="sec">0.0</b><span class="fr__metaUnit">s to rest</span></p>
<p class="fr__meta">Same flick (16 rad/s); only the coefficient changes</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 마찰 감쇠(03) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 무대는 주황 무대의 파스텔 변형 — 무대 주황 토큰(채도 1.0) 대신 같은 색상의 연한
// 피치 톤(채도 .43 · 명도 .84)을 쓴다(BD26). 칸 여섯 색도 채도 .40 이하 파스텔.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage(#f2d8c8);
.fr {
--w1: #b3bfd4; // 파랑빛 파스텔 — 1번 칸
--w2: #d9c48f; // 골드 파스텔 — 2번 칸
--w3: #a9c4b0; // 세이지 파스텔 — 3번 칸
--w4: #c2a8d1; // 플럼 파스텔 — 4번 칸
--w5: #d8a3b4; // 로즈 파스텔 — 5번 칸
--w6: #9fc6c2; // 틸 파스텔 — 6번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.fr__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.fr__row { display: flex; gap: $sp-2; align-items: stretch; }
.fr__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: $r-card;
background: rgba($subject-ink, .05);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: grab; touch-action: none;
}
.fr__field:active { cursor: grabbing; }
.fr__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fr__cv { display: block; width: 100%; height: 100%; }
.fr__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.fr__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-blue-deep; font-variant-numeric: tabular-nums;
}
.fr__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.fr__sw { display: flex; gap: $sp-1; margin-top: $sp-1; }
.fr__btn {
border: 0; cursor: pointer;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba($subject-ink, .10);
color: $subject-ink;
font-weight: 700; font-size: 11px; line-height: 1.2;
font-family: inherit;
}
.fr__btn.is-on { background: $subject-blue-deep; color: $subject-cream; }
.fr__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fr__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
.fr__meta b { font-weight: 700; color: $subject-blue-deep; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.fr__metaUnit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.fr__hint { font-size: 8px; line-height: 1.3; }
.fr { gap: $sp-1; }
.fr__field { width: 150px; height: 150px; }
.fr__num { font-size: 16px; }
.fr__meta { margin-top: 0; font-size: 8px; }
.fr__btn { padding: $sp-1; font-size: 9px; }
}
// reduce — 루프가 안 돈다. 감쇠 곡선 전체를 미리 계산해 둔 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.fr.is-still .fr__field { cursor: default !important; }
.fr.is-still .fr__btn { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f2d8c8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fr {
--w1: #b3bfd4;
--w2: #d9c48f;
--w3: #a9c4b0;
--w4: #c2a8d1;
--w5: #d8a3b4;
--w6: #9fc6c2;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.fr__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.fr__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.fr__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: grab;
touch-action: none;
}
.fr__field:active {
cursor: grabbing;
}
.fr__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fr__cv {
display: block;
width: 100%;
height: 100%;
}
.fr__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.fr__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
}
.fr__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.fr__sw {
display: flex;
gap: 4px;
margin-top: 4px;
}
.fr__btn {
border: 0;
cursor: pointer;
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
font-family: inherit;
}
.fr__btn.is-on {
background: #0a4fe8;
color: #fff7e6;
}
.fr__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fr__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
.fr__meta b {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.fr__metaUnit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.fr__hint {
font-size: 8px;
line-height: 1.3;
}
.fr {
gap: 4px;
}
.fr__field {
width: 150px;
height: 150px;
}
.fr__num {
font-size: 16px;
}
.fr__meta {
margin-top: 0;
font-size: 8px;
}
.fr__btn {
padding: 4px;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.fr.is-still .fr__field {
cursor: default !important;
}
.fr.is-still .fr__btn {
cursor: default;
}
}// 마찰 감쇠(03) — requestAnimationFrame 루프에서 dt 를 재고 각도 += 각속도·dt,
// 각속도 ×= 마찰^dt 를 적분하면 튕김 직후엔 빠르다가 지수곡선으로 느려진다.
// 우측 그래프는 그 적분을 실시간으로 그린다 — 판의 각속도와 그래프의 기울기가 같다.
(function () {
var root = document.getElementById('fr');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var coefEl = document.getElementById('coef');
var secEl = document.getElementById('sec');
var swEl = document.getElementById('sw');
if (!root || !field || !cv || !coefEl || !secEl || !swEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 6; i++) PAL.push(css.getPropertyValue('--w' + i).trim());
var INK = css.getPropertyValue('--ink').trim();
var CREAM = css.getPropertyValue('--cream').trim();
var TAU = Math.PI * 2;
var N = 6, SEG = TAU / N;
var COEFS = [0.08, 0.18, 0.38]; // 낮음·보통·높음 — 3levels 버튼
var ci = 1;
var FLICK = 16; // 시연 튕김은 항상 같다 — 계수만 바뀐다
var STOP = 0.5;
var ang = -0.8, angularVelocity = 0, t0 = 0, hist = [], settled = false, settleAt = 0, dpr = 1;
var MXW = 3.2; // 그래프에 담는 시야 (초) — 한 시행이 창을 가득 채운다
var sweep = -1; // 재시작 대기 중 그래프를 훑는 스캔라인 (0~1)
function fit() {
var w = field.clientWidth, h = field.clientHeight;
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
}
function draw() {
var w = cv.width / dpr, h = cv.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var cx = w * 0.28, cy = h * 0.5, R = Math.min(w * 0.56, h * 0.94) / 2 - 4;
// 판
ctx.save(); ctx.translate(cx, cy); ctx.rotate(ang);
for (var k = 0; k < N; k++) {
var a0 = k * SEG;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + SEG); ctx.closePath();
ctx.fillStyle = PAL[k]; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.20)'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 11, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
// 정지 뒤 허브 펄스 — 대기 중에도 판이 산다
if (sweep >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 11 + sweep * 16, 0, TAU);
ctx.strokeStyle = 'rgba(23,20,26,' + (1 - sweep) * 0.5 + ')';
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 6, cy - R - 8); ctx.lineTo(cx + 6, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
// 감쇠 그래프 — 우측 패널. x = 경과 초, y = 각속도 (0~FLICK). 판 옆 자리를 최소화한다
var gx = w * 0.56, gw = w - gx - 8, gy = 10, gh = h - 20;
ctx.strokeStyle = 'rgba(23,20,26,.30)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + gh); ctx.lineTo(gx + gw, gy + gh); ctx.stroke();
// 보조 눈금 — 25%·50%·75%
for (var gI = 1; gI <= 3; gI++) {
var yy = gy + gh * (1 - gI / 4);
ctx.beginPath(); ctx.moveTo(gx, yy); ctx.lineTo(gx + 4, yy);
ctx.strokeStyle = 'rgba(23,20,26,.25)'; ctx.stroke();
}
if (hist.length > 1) {
ctx.beginPath();
for (var j = 0; j < hist.length; j++) {
var px = gx + (hist[j].t / MXW) * gw;
var py = gy + gh * (1 - hist[j].v / (FLICK + 1));
if (j === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.strokeStyle = INK; ctx.lineWidth = 2; ctx.stroke();
// 현재 점
var lp = hist[hist.length - 1];
ctx.beginPath();
ctx.arc(gx + (lp.t / MXW) * gw, gy + gh * (1 - lp.v / (FLICK + 1)), 3, 0, TAU);
ctx.fillStyle = INK; ctx.fill();
}
// 스캔라인 — 재시작 대기 중 시야를 훑는다. 그래프가 다시 그려질 시점을 예고한다
if (sweep >= 0) {
var sx = gx + sweep * gw;
ctx.beginPath(); ctx.moveTo(sx, gy); ctx.lineTo(sx, gy + gh);
ctx.strokeStyle = 'rgba(23,20,26,.4)'; ctx.lineWidth = 1; ctx.stroke();
ctx.beginPath(); ctx.arc(sx, gy + gh, 3, 0, TAU);
ctx.fillStyle = INK; ctx.fill();
}
}
// 계수 버튼 3개
var btns = [];
for (var b = 0; b < COEFS.length; b++) {
(function (idx) {
var el = document.createElement('button');
el.type = 'button';
el.className = 'fr__btn' + (idx === ci ? ' is-on' : '');
el.textContent = String(COEFS[idx]);
el.addEventListener('click', function () {
ci = idx;
coefEl.textContent = COEFS[idx].toFixed(2);
for (var q = 0; q < btns.length; q++) btns[q].className = 'fr__btn' + (q === idx ? ' is-on' : '');
relaunch();
});
btns.push(el); swEl.appendChild(el);
})(b);
}
var drag = { on: false, lastA: 0, lastT: 0, v: 0 };
function angleAt(e) {
var b = field.getBoundingClientRect();
return Math.atan2(e.clientY - (b.top + b.height / 2), e.clientX - (b.left + b.width / 2));
}
field.addEventListener('pointerdown', function (e) {
drag.on = true; drag.lastA = angleAt(e); drag.lastT = performance.now(); drag.v = 0;
if (field.setPointerCapture) { try { field.setPointerCapture(e.pointerId); } catch (err) { /* 캡처 실패는 회전에 영향 없다 */ } }
});
field.addEventListener('pointermove', function (e) {
if (!drag.on) return;
var a = angleAt(e), now = performance.now();
var dA = a - drag.lastA;
if (dA > Math.PI) dA -= TAU;
if (dA < -Math.PI) dA += TAU;
ang += dA;
var dt = Math.max(1, now - drag.lastT) / 1000;
drag.v = 0.65 * drag.v + 0.35 * (dA / dt);
drag.lastA = a; drag.lastT = now;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
var v = Math.max(-24, Math.min(24, drag.v));
start(Math.abs(v) < 1 ? FLICK : v);
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); start(FLICK); }
});
function start(v) {
angularVelocity = v; t0 = 0; hist = [{ t: 0, v: Math.abs(v) }]; settled = false; sweep = -1;
secEl.textContent = '0.0';
}
function relaunch() { start(FLICK); }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var last = performance.now();
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (!drag.on) {
if (angularVelocity !== 0) {
ang += angularVelocity * dt;
var before = Math.abs(angularVelocity);
angularVelocity *= Math.pow(COEFS[ci], dt);
t0 += dt;
if (Math.abs(angularVelocity) < STOP) {
angularVelocity = 0; settled = true; settleAt = t0;
secEl.textContent = settleAt.toFixed(1);
hist.push({ t: t0, v: 0 });
} else {
hist.push({ t: t0, v: Math.abs(angularVelocity) });
}
if (hist.length > 200) hist.shift();
} else if (settled && t0 > settleAt + 1.0) {
relaunch();
} else if (!settled) { relaunch(); }
if (settled) {
t0 += dt;
sweep = Math.max(0, Math.min(1, (t0 - settleAt) / 1.0));
}
}
draw();
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 계수 0.18 로 시뮬레이션한 감쇠 곡선 전체를 미리 계산해 그린다
var vv = FLICK, tt = 0, rr = [];
rr.push({ t: 0, v: vv });
while (Math.abs(vv) >= STOP && tt < MXW) { vv *= Math.pow(0.18, 1 / 60); tt += 1 / 60; rr.push({ t: tt, v: Math.abs(vv) }); }
hist = rr; ang = -0.8 + FLICK * 0.35; sweep = 0.55;
draw();
root.classList.add('is-still');
} else {
start(FLICK);
requestAnimationFrame(step);
}
})();// SpinWheelFrictionDecel — vanilla 판(run/332/demo/03-spin-wheel-friction-decel/index.html) 의 React 포트.
// rAF 루프에서 dt 를 재고 각도 += 각속도·dt, 각속도 ×= 계수^dt 를 적분한다. 우측 그래프는
// 그 적분을 실시간 꺾은선으로 그린다 — 판의 각속도와 그래프 기울기가 같다. 튕김 세기(FLICK=16)는
// 항상 같고 계수 3단계(낮음·보통·높음)만 바뀐다. 정지 뒤 1초 뒤 스캔라인이 훑고 자동 재시작한다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelFrictionDecel.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelFrictionDecelProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type HistPt = { t: number; v: number };
const TAU = Math.PI * 2;
const N = 6, SEG = TAU / N;
const COEFS = [0.08, 0.18, 0.38]; // 낮음·보통·높음 — 3단계 버튼
const FLICK = 16; // 시연 튕김은 항상 같다 — 계수만 바뀐다
const STOP = 0.5;
const MXW = 3.2; // 그래프에 담는 시야(초) — 한 시행이 창을 가득 채운다
const PAL = ["#b3bfd4", "#d9c48f", "#a9c4b0", "#c2a8d1", "#d8a3b4", "#9fc6c2"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "rAF 루프에서 dt 를 재고 각도 += 각속도·dt, 각속도 ×= 마찰^dt 를 적분합니다";
const ARIA_FIELD = "마찰 감쇠 실험판";
const ARIA_SW = "마찰 계수 고르기";
const UNIT = "마찰 계수 — 매초 각속도에 곱한다";
const META_LABEL = "정지까지 ";
const META_UNIT = "초 소요";
const META_FIXED = "같은 튕김(16 rad/s)으로 계수만 바뀝니다";
export default function SpinWheelFrictionDecel({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: SpinWheelFrictionDecelProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const secRef = useRef<HTMLSpanElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const [ci, setCi] = useState(1);
const ciRef = useRef(1) as Box<number>; // step() 클로저 안에서 최신 값을 바로 읽기 위한 거울
const stRef = useRef({
ang: -0.8, vel: 0, t0: 0, hist: [{ t: 0, v: FLICK }] as HistPt[],
settled: false, settleAt: 0, sweep: -1, dpr: 1,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; t0: number; hist: HistPt[];
settled: boolean; settleAt: number; sweep: number; dpr: number;
drag: { on: boolean; lastA: number; lastT: number; v: number };
}>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
st.dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(field.clientWidth * st.dpr);
cv.height = Math.round(field.clientHeight * st.dpr);
ctxRef.current = ctx;
}
function draw(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const w = cvRef.current!.width / st.dpr, h = cvRef.current!.height / st.dpr;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const cx = w * 0.28, cy = h * 0.5, R = Math.min(w * 0.56, h * 0.94) / 2 - 4;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(st.ang);
for (let k = 0; k < N; k++) {
const a0 = k * SEG;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + SEG); ctx.closePath();
ctx.fillStyle = PAL[k]; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.20)"; ctx.lineWidth = 1; ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 11, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
if (st.sweep >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 11 + st.sweep * 16, 0, TAU);
ctx.strokeStyle = "rgba(23,20,26," + (1 - st.sweep) * 0.5 + ")";
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 6, cy - R - 8); ctx.lineTo(cx + 6, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
// 감쇠 그래프 — 우측 패널. x = 경과 초, y = 각속도(0~FLICK)
const gx = w * 0.56, gw = w - gx - 8, gy = 10, gh = h - 20;
ctx.strokeStyle = "rgba(23,20,26,.30)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + gh); ctx.lineTo(gx + gw, gy + gh); ctx.stroke();
for (let gI = 1; gI <= 3; gI++) {
const yy = gy + gh * (1 - gI / 4);
ctx.beginPath(); ctx.moveTo(gx, yy); ctx.lineTo(gx + 4, yy);
ctx.strokeStyle = "rgba(23,20,26,.25)"; ctx.stroke();
}
const hist = st.hist;
if (hist.length > 1) {
ctx.beginPath();
for (let j = 0; j < hist.length; j++) {
const px = gx + (hist[j].t / MXW) * gw;
const py = gy + gh * (1 - hist[j].v / (FLICK + 1));
if (j === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.strokeStyle = INK; ctx.lineWidth = 2; ctx.stroke();
const lp = hist[hist.length - 1];
ctx.beginPath();
ctx.arc(gx + (lp.t / MXW) * gw, gy + gh * (1 - lp.v / (FLICK + 1)), 3, 0, TAU);
ctx.fillStyle = INK; ctx.fill();
}
if (st.sweep >= 0) {
const sx = gx + st.sweep * gw;
ctx.beginPath(); ctx.moveTo(sx, gy); ctx.lineTo(sx, gy + gh);
ctx.strokeStyle = "rgba(23,20,26,.4)"; ctx.lineWidth = 1; ctx.stroke();
ctx.beginPath(); ctx.arc(sx, gy + gh, 3, 0, TAU);
ctx.fillStyle = INK; ctx.fill();
}
}
function start(v: number): void {
const st = stRef.current;
st.vel = v; st.t0 = 0; st.hist = [{ t: 0, v: Math.abs(v) }]; st.settled = false; st.sweep = -1;
const sec = secRef.current;
if (sec) sec.textContent = "0.0";
}
function relaunch(): void { start(FLICK); }
function angleAt(e: PointerEvent<HTMLDivElement>): number {
const b = e.currentTarget.getBoundingClientRect();
return Math.atan2(e.clientY - (b.top + b.height / 2), e.clientX - (b.left + b.width / 2));
}
function onPointerDown(e: PointerEvent<HTMLDivElement>): void {
const st = stRef.current;
st.drag.on = true; st.drag.lastA = angleAt(e); st.drag.lastT = performance.now(); st.drag.v = 0;
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 회전에 영향 없다 */ }
}
function onPointerMove(e: PointerEvent<HTMLDivElement>): void {
const st = stRef.current;
if (!st.drag.on) return;
const a = angleAt(e), now = performance.now();
let dA = a - st.drag.lastA;
if (dA > Math.PI) dA -= TAU;
if (dA < -Math.PI) dA += TAU;
st.ang += dA;
const dt = Math.max(1, now - st.drag.lastT) / 1000;
st.drag.v = 0.65 * st.drag.v + 0.35 * (dA / dt);
st.drag.lastA = a; st.drag.lastT = now;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
const v = Math.max(-24, Math.min(24, st.drag.v));
start(Math.abs(v) < 1 ? FLICK : v);
}
function onKeyDown(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); start(FLICK); }
}
function onCoefClick(idx: number): void {
ciRef.current = idx;
setCi(idx);
relaunch();
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
const sec = secRef.current;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(COEFS[ciRef.current], dt);
st.t0 += dt;
if (Math.abs(st.vel) < STOP) {
st.vel = 0; st.settled = true; st.settleAt = st.t0;
if (sec) sec.textContent = st.settleAt.toFixed(1);
st.hist.push({ t: st.t0, v: 0 });
} else {
st.hist.push({ t: st.t0, v: Math.abs(st.vel) });
}
if (st.hist.length > 200) st.hist.shift();
} else if (st.settled && st.t0 > st.settleAt + 1.0) {
relaunch();
} else if (!st.settled) { relaunch(); }
if (st.settled) {
st.t0 += dt;
st.sweep = Math.max(0, Math.min(1, (st.t0 - st.settleAt) / 1.0));
}
}
draw();
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 계수 0.18 로 시뮬레이션한 감쇠 곡선 전체를 미리 계산해 그린다
let vv = FLICK, tt = 0;
const rr: HistPt[] = [{ t: 0, v: vv }];
while (Math.abs(vv) >= STOP && tt < MXW) { vv *= Math.pow(0.18, 1 / 60); tt += 1 / 60; rr.push({ t: tt, v: Math.abs(vv) }); }
const st = stRef.current;
st.hist = rr; st.ang = -0.8 + FLICK * 0.35; st.sweep = 0.55;
draw();
setStill(true);
} else {
start(FLICK);
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rootCls = still ? styles.fr + " " + styles.isStill : styles.fr;
return (
<main className={styles.stage}>
<div className={rootCls} style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row}>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={ARIA_FIELD}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.side}>
<b className={styles.num}>{COEFS[ci].toFixed(2)}</b>
<span className={styles.unit}>{UNIT}</span>
<div className={styles.sw} role="group" aria-label={ARIA_SW}>
{COEFS.map((c, idx) => (
<button
key={idx}
type="button"
className={idx === ci ? styles.btn + " " + styles.isOn : styles.btn}
onClick={() => onCoefClick(idx)}
>
{c}
</button>
))}
</div>
<p className={styles.meta}>{META_LABEL}<b className={styles.count} ref={secRef}>0.0</b><span className={styles.metaUnit}>{META_UNIT}</span></p>
<p className={styles.meta}>{META_FIXED}</p>
</div>
</div>
</div>
</main>
);
}03 Friction decay — a disc that integrates each frame to a stop — Measured area 9.394% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Spin Wheel: 9 Canvas Roulettes, Flick to Stop
The post this demo lives in
Spin Wheel: 9 Canvas Roulettes, Flick to StopIt spins exactly as hard as you flick it. Angular velocity is integrated, friction decays it, and the sector under the p See all demos in this post


