룰렛 돌리기 — 튕겨 돌리고 마찰로 멈추는 돌림판 캔버스 9종
손가락으로 튕긴 만큼만 돌아갑니다. 각속도를 적분하고 마찰로 감쇠해 포인터 아래 칸을 계산으로 판정합니다. 가중치 확률·연속 뽑기·당첨 폭죽까지 — 캔버스 한 장, 코드는 비밀번호 zip으로 드립니다. 룰렛 돌리기를 브라우저에서 직접 만지는 아홉 개의 완성 판을 순서대로 조립합니다.
01 spin-wheel-canvas-sectors
02 spin-wheel-flick-velocity
03 spin-wheel-friction-decel
04 spin-wheel-weighted-odds
05 spin-wheel-pointer-peg
06 spin-wheel-result-snap
07 spin-wheel-entry-editor
08 spin-wheel-elimination-mode
09 spin-wheel-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 부채꼴 바퀴 — 극좌표 호로 칸을 잇는 판때기
- 02 튕김 각속도 — 포인터 이동을 회전 속도로 바꾸기
- 03 마찰 감쇠 — 매 프레임 적분해 스스로 멈추는 판
- 04 가중치 확률 — 칸 각도를 무게로 나눠 공정하게 뽑기
- 05 포인터 딸깍 — 칸 경계를 지날 때마다 튕기는 소리와 흔들림
- 06 당첨 판정 — 정지 각도를 부채꼴 인덱스로 환산하기
- 07 항목 편집기 — 이름 줄과 칸 각도가 함께 다시 계산되는 판
- 08 연속 뽑기 — 당첨 칸을 빼고 도는 탈락 모드
- 09 히어로 씬 — 폭죽 터지고 결과를 저장하는 완성판
데모
아홉 판 전부 index.html 한 파일씩으로 열리자마자 스스로 돕니다. 계산 사슬은 하나입니다 — pointermove 로 받은 각도 차를 Math.atan2 로 각속도로 바꾸고, 지수이동평균(0.65/0.35)으로 다듬고, requestAnimationFrame 루프에서 매 프레임 마찰^dt 를 곱해 적분하다가, 각속도가 임계값 아래로 떨어지면 12시 고정 포인터 기준으로 정규화해 부채꼴 인덱스를 판정합니다. 사람이 드래그하면 자동 시연이 멈추고 같은 루프로 이어 돕니다.
실측 한눈에
| 항목 | 초점 | 누적면적(%) | 면적최대(%) | 강도 평균 |
|---|---|---|---|---|
| 01 부채꼴 바퀴 | moveTo→arc→closePath · 2π/n 균등 | 23.344 | 22.728 | 55.9 |
| 02 튕김 각속도 | atan2 · EMA 0.65/0.35 · ±24 clamp | 23.217 | 22.120 | 52.4 |
| 03 마찰 감쇠 | 각도 += v·dt · v ×= 마찰^dt · 그래프 | 9.394 | 7.138 | 45.5 |
| 04 가중치 확률 | 누적 bounds · W=[5,3,2,1,3,2] | 22.052 | 17.906 | 56.0 |
| 05 포인터 딸깍 | 몫 변화 감지 · 스프링 pk=340·pc=14 | 22.926 | 21.906 | 55.8 |
| 06 당첨 판정 | norm(−π/2−각도) · aria-live | 22.293 | 18.542 | 53.7 |
| 07 항목 편집기 | measureText 이분 탐색 6~15px | 21.903 | 11.780 | 62.0 |
| 08 연속 뽑기 | splice 제거 · log.pop 되돌리기 | 21.825 | 15.651 | 50.6 |
| 09 히어로 씬 | 파티클 42개 · toDataURL 저장 | 22.094 | 20.947 | 60.1 |
낮은 누적면적이 정상인 이유
03 마찰 감쇠가 9.394% 로 유일하게 한 자릿수인 것은 결함이 아니라 구조입니다 — 화면 절반이 감속 곡선을 실시간으로 그리는 그래프 패널이라 회전판 자체의 면적이 좁고, 그래프의 짙은 배경 탓에 강도 평균 45.5 도 아홉 판 중 가장 어둡습니다. 08 연속 뽑기의 21.825% 는 당첨자가 옮겨 적히는 사이드 기록 판이 화면 폭을 나눠 가진 탓이고, 07 항목 편집기는 강도 평균 62.0 으로 가장 밝습니다. 나머지 일곱 판은 22~23% 대로 고르게 넓은 회전판을 차지합니다. 05 는 소리가 나는 판이지만 틱은 사람이 직접 돌리는 중에만 재생되므로 녹화·자동 시연에서는 무음인 것이 정상 동작입니다.
튕김은 왜 물리인가 — 각속도 적분과 마찰 감쇠
CSS transition 의 감속 이징은 튕긴 세기를 반영하지 못합니다 — 미리 정해둔 곡선대로 도는 것이지 입력이 결과를 바꾸는 것이 아닙니다. 이 아홉 판은 매 프레임 실제로 계산합니다 — pointermove 이벤트가 잡힐 때마다 중심에서의 각도를 Math.atan2 로 재고 이전 각도와의 차(dA, ±π 경계는 TAU 로 보정)를 경과 시간으로 나누면 rad/s 각속도가 나오며, 순간값 대신 drag.v = 0.65 * drag.v + 0.35 * (dA / dt) 지수이동평균으로 다듬어 손 떨림이 회전에 그대로 묻지 않게 합니다. 손을 뗀 순간의 값이 초기 속도가 되어 ±24 로 clamp 뒤 루프에 들어가고, 루프에서는 각도 += 각속도 * dt; 각속도 *= Math.pow(마찰계수, dt) 를 반복해 지수곡선으로 느려지다가 절대값이 임계값(STOP 0.4~0.5) 아래로 떨어지면 끝납니다 — 프레임 수를 세어 멈추지 않습니다. dt 는 64ms 상한으로 clamp 하는데, 탭이 배경으로 가 프레임이 오래 끊기면 dt 가 커져 판이 한 번에 몇 바퀴를 건너뛰기 때문입니다. 세기를 바꾸면 회전 시간과 정지 각도가 실제로 달라지는 것이 물리와 애니메이션을 가르는 기준입니다.
당첨은 각도 계산이다 — 포인터 판정과 가중치
판이 멈췄을 때 당첨을 정하는 것은 화면 연출이 아니라 두 값입니다 — 정지 각도와 칸 경계. 06 의 idxAt 는 균등 분할에서 Math.floor(norm(-Math.PI / 2 - a) / SEG) % N 한 줄로 끝나고, 04·09 의 가중치 판은 같은 정규화 값을 칸 각도로 나누는 대신 누적 bounds 배열의 어느 구간에 떨어지는지 찾습니다. 어느 쪽이든 포인터가 12시에 고정되어 있다는 사실 하나에서 나오는 계산이므로, 칸을 늘리거나(07) 당첨자를 빼거나(08) 해서 배치가 바뀌면 판정식에 들어가는 분모와 경계만 새로 계산하면 됩니다 — 이것이 룰렛 돌리기 사이트를 만들 때 가장 많이 틀리는 지점으로, 화면에 보이는 각도와 판정에 쓰는 각도가 어긋나면 눈으로는 A 칸인데 결과가 B 로 뜹니다.
아홉 도구 단계별 코드
01부채꼴 바퀴 — 극좌표 호로 칸을 잇는 판때기
칸 하나는 moveTo(0, 0) → arc(0, 0, R, 시작각, 끝각) → closePath 의 판때기이고 n=8 칸을 45° 씩 이어 붙이면 돌림판 바닥이 완성됩니다. 색은 팔레트 배열을 순환시켜 이웃 칸이 같은 색으로 붙지 않게 하며, 자동 시연은 고정 튕김 세 값 [9.6, −7.4, 12.6] 을 순회해 매번 같은 회전을 재현합니다.

<main class="stage">
<div class="ww" id="ww">
<p class="ww__hint">moveTo → arc → closePath 로 칸을 그리고 팔레트를 순환시켜 이웃 칸이 같은 색으로 안 겹치게 합니다</p>
<div class="ww__row">
<div class="ww__field" id="field" role="application" aria-label="부채꼴 바퀴" tabindex="0">
<canvas class="ww__cv" id="cv"></canvas>
</div>
<div class="ww__side">
<b class="ww__num" id="num">8</b>
<span class="ww__unit">칸 · 호 각도 45°</span>
<div class="ww__dots" id="dots"></div>
<p class="ww__meta">드래그로 돌리고 놓으면 관성으로 돕니다</p>
<p class="ww__meta"><b class="ww__count" id="deg">0</b><span class="ww__metaUnit">° 누적 회전</span></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 부채꼴 바퀴(01) — 움직임은 전부 캔버스 rAF 루프다(077 방식). CSS @keyframes 를 두지
// 않는다 — 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다.
// 칸 여덟 색은 파스텔 팔레트(채도 .34 내외 · 명도 .70~.74) — 이웃 칸이 같은 색으로 붙지
// 않게 색상을 45° 보다 넓게 벌렸다. BD26 vivid 기준(채도≥.55) 전부 통과.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.ww {
--w1: #a6b4d3; // 파랑빛 파스텔 — 1번 칸
--w2: #d9c48f; // 골드 파스텔 — 2번 칸
--w3: #a6d3b1; // 연두 파스텔 — 3번 칸
--w4: #d8a3b4; // 로즈 파스텔 — 4번 칸
--w5: #9fc6c2; // 틸 파스텔 — 5번 칸
--w6: #c2a8d1; // 플럼 파스텔 — 6번 칸
--w7: #e0c6a2; // 모래 파스텔 — 7번 칸
--w8: #a9c4b0; // 세이지 파스텔 — 8번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.ww__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.ww__row { display: flex; gap: $sp-2; align-items: stretch; }
.ww__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-cream, .04);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: grab; touch-action: none;
}
.ww__field:active { cursor: grabbing; }
.ww__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ww__cv { display: block; width: 100%; height: 100%; }
.ww__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.ww__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.ww__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .82); }
.ww__dots { display: flex; gap: $sp-1; flex-wrap: wrap; margin-top: $sp-1; }
.ww__dot {
width: 12px; height: 12px; border-radius: $r-pill;
background: rgba($subject-cream, .18);
}
.ww__dot.is-on { background: $subject-sky; }
.ww__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .82); }
.ww__meta b { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.ww__metaUnit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.ww__hint { font-size: 8px; line-height: 1.3; }
.ww { gap: $sp-1; }
.ww__field { width: 150px; height: 150px; }
.ww__num { font-size: 16px; }
.ww__meta { margin-top: 0; font-size: 8px; }
.ww__dot { width: 8px; height: 8px; }
}
// reduce — 루프가 안 돈다. 포인터 아래 칸이 켜진 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.ww.is-still .ww__field { cursor: default !important; }
}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);
}
.ww {
--w1: #a6b4d3;
--w2: #d9c48f;
--w3: #a6d3b1;
--w4: #d8a3b4;
--w5: #9fc6c2;
--w6: #c2a8d1;
--w7: #e0c6a2;
--w8: #a9c4b0;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.ww__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.ww__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.ww__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: grab;
touch-action: none;
}
.ww__field:active {
cursor: grabbing;
}
.ww__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.ww__cv {
display: block;
width: 100%;
height: 100%;
}
.ww__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.ww__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.ww__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.82);
}
.ww__dots {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.ww__dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
}
.ww__dot.is-on {
background: #6ec8ff;
}
.ww__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
}
.ww__meta b {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.ww__metaUnit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.ww__hint {
font-size: 8px;
line-height: 1.3;
}
.ww {
gap: 4px;
}
.ww__field {
width: 150px;
height: 150px;
}
.ww__num {
font-size: 16px;
}
.ww__meta {
margin-top: 0;
font-size: 8px;
}
.ww__dot {
width: 8px;
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ww.is-still .ww__field {
cursor: default !important;
}
}// 부채꼴 바퀴(01) — 칸 하나는 moveTo(중심) → arc(시작각, 끝각) → closePath 의 판때기다.
// 각도는 2π/n 으로 균등 분할하고 색은 팔레트 배열을 순환시켜 이웃 칸이 같은 색으로 붙지 않게 한다.
// 움직임은 전부 rAF 캔버스 — 정지 뒤 허브 링 펄스로 마지막 프레임까지 화면이 산다.
(function () {
var root = document.getElementById('ww');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var dotsEl = document.getElementById('dots');
var degEl = document.getElementById('deg');
var numEl = document.getElementById('num');
if (!root || !field || !cv || !dotsEl || !degEl || !numEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 8; 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 = 8, SEG = TAU / N;
numEl.textContent = String(N);
var ang = -0.62, vel = 0, total = 0;
var FRICTION = 0.30; // 감쇠 비율 (매초 곱한다) — 각속도 ×= FRICTION^dt
var STOP = 0.45; // 이 밑으로 떨어지면 정지로 본다 (rad/s)
var settle = -1; // 정지 뒤 펄스 타이머 (-1 = 회전 중)
var pulse = -1; // 허브 링 펄스 진행 (0~1)
var FLICKS = [9.6, -7.4, 12.6]; // 자동 데모 튕김 세 자루 — 시드 없이 고정, 항상 같은 시연
var fi = 0, lastIdx = -1, dpr = 1;
for (var d = 0; d < N; d++) {
var s = document.createElement('span');
s.className = 'ww__dot';
dotsEl.appendChild(s);
}
var dots = dotsEl.children;
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 norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
// 12시 고정 포인터 기준 당첨 칸 — 판 좌표에서 -π/2 를 되돌린 뒤 칸 각도로 나눈다
function judge() {
return Math.floor(norm(-Math.PI / 2 - ang) / SEG) % N;
}
function drawWheel() {
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 8;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(ang);
for (var k = 0; k < N; k++) {
var a0 = k * SEG, a1 = a0 + SEG;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1);
ctx.closePath();
ctx.fillStyle = PAL[k];
ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.22)';
ctx.lineWidth = 1;
ctx.stroke();
// 칸 번호 — 가운데 각도 반지름 방향으로 바깥에서 안으로 읽힌다
ctx.save();
ctx.rotate(a0 + SEG / 2);
ctx.fillStyle = INK;
ctx.font = '700 13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(k + 1), R - 14, 0);
ctx.restore();
}
// 테두리 핀 — 칸 경계마다 하나
for (var p = 0; p < N; p++) {
var pa = p * SEG;
ctx.beginPath();
ctx.arc(Math.cos(pa) * (R - 3), Math.sin(pa) * (R - 3), 2.6, 0, TAU);
ctx.fillStyle = CREAM;
ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.35)';
ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 15, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
ctx.beginPath(); ctx.arc(0, 0, 5, 0, TAU);
ctx.fillStyle = INK; ctx.fill();
// 정지 펄스 링 — 멈춘 뒤 0.3초 퍼졌다 사라진다
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 15 + pulse * 26, 0, TAU);
ctx.strokeStyle = 'rgba(255,247,230,' + (1 - pulse) * 0.8 + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
// 12시 고정 포인터 — 판과 함께 돌지 않는다
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 6);
ctx.lineTo(cx + 7, cy - R - 6);
ctx.lineTo(cx, cy - R + 9);
ctx.closePath();
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
}
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; total += Math.abs(dA) * 180 / Math.PI;
var dt = Math.max(1, now - drag.lastT) / 1000;
drag.v = 0.7 * drag.v + 0.3 * (dA / dt); // 최근 이동의 지수 평균 — 손을 뗄 때 각속도가 된다
drag.lastA = a; drag.lastT = now;
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-22, Math.min(22, drag.v));
settle = -1; pulse = -1; idle = 0;
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
vel = FLICKS[fi++ % FLICKS.length]; settle = -1; pulse = -1; idle = 0;
} else if (e.key === 'ArrowLeft') { ang -= SEG / 2; total += 22.5; idle = 0; }
else if (e.key === 'ArrowRight') { ang += SEG / 2; total += 22.5; idle = 0; }
});
var idle = 0; // 손 입력 뒤 자동 시연을 잠시 쉬게 하는 타이머 (초)
function auto(dt) {
if (idle > 0) { idle -= dt; return; }
if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 0.34) { vel = FLICKS[fi++ % FLICKS.length]; settle = -1; pulse = -1; }
return;
}
vel *= Math.pow(FRICTION, dt);
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; }
}
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 (vel !== 0) {
ang += vel * dt; total += Math.abs(vel) * dt * 180 / Math.PI;
vel *= Math.pow(FRICTION, dt);
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; }
} else { auto(dt); }
}
drawWheel();
var idx = judge();
if (idx !== lastIdx) {
for (var q = 0; q < N; q++) dots[q].className = q === idx ? 'ww__dot is-on' : 'ww__dot';
lastIdx = idx;
}
degEl.textContent = String(Math.round(total));
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 펄스 링이 퍼진 상태로 고정하고 루프는 돌지 않는다
pulse = 0.45;
drawWheel();
var idx0 = judge();
for (var z = 0; z < N; z++) dots[z].className = z === idx0 ? 'ww__dot is-on' : 'ww__dot';
root.classList.add('is-still');
} else {
vel = FLICKS[0]; fi = 1;
requestAnimationFrame(step);
}
})();// SpinWheelCanvasSectors — vanilla 판(run/332/demo/01-spin-wheel-canvas-sectors/index.html) 의 React 포트.
// 칸 하나는 moveTo(중심) → arc(시작각, 끝각) → closePath 의 판때기다. 각도는 2π/n 균등 분할,
// 색은 팔레트 순환이라 이웃 칸이 같은 색으로 붙지 않는다. 각속도 → 마찰 적분(FRICTION^dt) →
// 정지 뒤 −π/2 기준 부채꼴 판정(judge) 사슬이 그대로 있다. 자동 튕김 세 자루(FLICKS)는
// 시드 없는 고정값 — 항상 같은 시연이 반복된다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 펄스 링이 퍼진 정지 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelCanvasSectors.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelCanvasSectorsProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const N = 8, SEG = TAU / N;
const FRICTION = 0.30; // 감쇠 비율 (매초 곱한다) — 각속도 ×= FRICTION^dt
const STOP = 0.45; // 이 밑으로 떨어지면 정지로 본다 (rad/s)
const FLICKS = [9.6, -7.4, 12.6]; // 자동 데모 튕김 세 자루 — 시드 없이 고정, 항상 같은 시연
const PAL = ["#a6b4d3", "#d9c48f", "#a6d3b1", "#d8a3b4", "#9fc6c2", "#c2a8d1", "#e0c6a2", "#a9c4b0"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "moveTo → arc → closePath 로 칸을 그리고 팔레트를 순환시켜 이웃 칸이 같은 색으로 안 겹치게 합니다";
const ARIA_FIELD = "부채꼴 바퀴";
const UNIT = "칸 · 호 각도 45°";
const META_DRAG = "드래그로 돌리고 놓으면 관성으로 돕니다";
const DEG_UNIT = "° 누적 회전";
export default function SpinWheelCanvasSectors({
duration = "2s",
easing = "ease-out",
color = "#6ec8ff"
}: SpinWheelCanvasSectorsProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const degRef = useRef<HTMLSpanElement | null>(null);
const dotsBoxRef = useRef<HTMLDivElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
ang: -0.62, vel: 0, total: 0,
settle: -1, pulse: -1, fi: 1, lastIdx: -1, dpr: 1, idle: 0,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; total: number;
settle: number; pulse: number; fi: number; lastIdx: number; dpr: number; idle: 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 norm(a: number): number {
const r = a % TAU;
return r < 0 ? r + TAU : r;
}
// 12시 고정 포인터 기준 당첨 칸 — 판 좌표에서 -π/2 를 되돌린 뒤 칸 각도로 나눈다
function judge(): number {
const st = stRef.current;
return Math.floor(norm(-Math.PI / 2 - st.ang) / SEG) % N;
}
function drawWheel(): 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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 8;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(st.ang);
for (let k = 0; k < N; k++) {
const a0 = k * SEG, a1 = a0 + SEG;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1);
ctx.closePath();
ctx.fillStyle = PAL[k];
ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.22)";
ctx.lineWidth = 1;
ctx.stroke();
// 칸 번호 — 가운데 각도 반지름 방향으로 바깥에서 안으로 읽힌다
ctx.save();
ctx.rotate(a0 + SEG / 2);
ctx.fillStyle = INK;
ctx.font = "700 13px sans-serif";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillText(String(k + 1), R - 14, 0);
ctx.restore();
}
// 테두리 핀 — 칸 경계마다 하나
for (let p = 0; p < N; p++) {
const pa = p * SEG;
ctx.beginPath();
ctx.arc(Math.cos(pa) * (R - 3), Math.sin(pa) * (R - 3), 2.6, 0, TAU);
ctx.fillStyle = CREAM;
ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.35)";
ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 15, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
ctx.beginPath(); ctx.arc(0, 0, 5, 0, TAU);
ctx.fillStyle = INK; ctx.fill();
// 정지 펄스 링 — 멈춘 뒤 0.3초 퍼졌다 사라진다
if (st.pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 15 + st.pulse * 26, 0, TAU);
ctx.strokeStyle = "rgba(255,247,230," + (1 - st.pulse) * 0.8 + ")";
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
// 12시 고정 포인터 — 판과 함께 돌지 않는다
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 6);
ctx.lineTo(cx + 7, cy - R - 6);
ctx.lineTo(cx, cy - R + 9);
ctx.closePath();
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
}
// 당첨 칸 도트 갱신 — 포인터 아래 칸만 켠다
function paintDots(idx: number): void {
const box = dotsBoxRef.current;
if (!box) return;
for (let q = 0; q < box.children.length; q++) {
const el = box.children[q] as HTMLElement;
el.className = q === idx ? styles.dot + " " + styles.isOn : styles.dot;
}
}
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; st.total += Math.abs(dA) * 180 / Math.PI;
const dt = Math.max(1, now - st.drag.lastT) / 1000;
st.drag.v = 0.7 * st.drag.v + 0.3 * (dA / dt); // 최근 이동의 지수 평균 — 손을 뗄 때 각속도가 된다
st.drag.lastA = a; st.drag.lastT = now;
st.idle = 0;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-22, Math.min(22, st.drag.v));
st.settle = -1; st.pulse = -1; st.idle = 0;
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; st.idle = 0;
} else if (e.key === "ArrowLeft") { st.ang -= SEG / 2; st.total += 22.5; st.idle = 0; }
else if (e.key === "ArrowRight") { st.ang += SEG / 2; st.total += 22.5; st.idle = 0; }
}
// 손 입력 뒤 자동 시연을 잠시 쉬게 하는 타이머 (초)
function auto(dt: number): void {
const st = stRef.current;
if (st.idle > 0) { st.idle -= dt; return; }
if (st.settle >= 0) {
st.settle += dt; st.pulse = Math.min(1, st.settle / 0.3);
if (st.settle > 0.34) { st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; }
return;
}
st.vel *= Math.pow(FRICTION, dt);
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; }
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt; st.total += Math.abs(st.vel) * dt * 180 / Math.PI;
st.vel *= Math.pow(FRICTION, dt);
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; }
} else { auto(dt); }
}
drawWheel();
const idx = judge();
if (idx !== st.lastIdx) {
paintDots(idx);
st.lastIdx = idx;
}
const deg = degRef.current;
if (deg) deg.textContent = String(Math.round(st.total));
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 펄스 링이 퍼진 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
st.pulse = 0.45;
drawWheel();
paintDots(judge());
setStill(true);
} else {
stRef.current.vel = FLICKS[0];
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.ww + " " + styles.isStill : styles.ww;
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}>{N}</b>
<span className={styles.unit}>{UNIT}</span>
<div className={styles.dots} ref={dotsBoxRef}>
{Array.from({ length: N }, (_, i) => <span key={i} className={styles.dot}></span>)}
</div>
<p className={styles.meta}>{META_DRAG}</p>
<p className={styles.meta}><b className={styles.count} ref={degRef}>0</b><span className={styles.metaUnit}>{DEG_UNIT}</span></p>
</div>
</div>
</div>
</main>
);
}01 · 부채꼴 바퀴 — 극좌표 호로 칸을 잇는 판때기 — 측정 누적면적 23.344% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
판을 그리는 열다섯 줄 — arc 하나가 칸 하나
var cx = w / 2, cy = h / 2, R = Math.min(w, h) / 2 - 8;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(ang);
for (var k = 0; k < N; k++) {
var a0 = k * SEG, a1 = a0 + SEG;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1);
ctx.closePath();
ctx.fillStyle = PAL[k];
ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.22)';
ctx.lineWidth = 1;
ctx.stroke();
누적면적 23.344%(면적최대 22.728%)로 아홉 판 중 가장 넓게 화면을 차지하는 판때기 바닥이며, ang 하나만 바꿔 다시 그리면 판 전체가 따라 돕니다.
02튕김 각속도 — 포인터 이동을 회전 속도로 바꾸기
pointermove 가 발생할 때마다 중심에서의 각도를 Math.atan2 로 재는 angleAt 이 입력의 전부이고, 이전 각도와의 차에 경과 시간을 나눈 값을 지수이동평균으로 다듬으면 rad/s 각속도가 나옵니다. 손을 뗀 순간 값을 ±24 로 clamp 해 초기 속도로 쓰고 세기를 5단계 바로 표시합니다.

<main class="stage">
<div class="fw" id="fw">
<p class="fw__hint">pointermove 마다 atan2 로 중심각을 재고 이전 각도·시간과의 차로 rad/s 를 만듭니다</p>
<div class="fw__row">
<div class="fw__field" id="field" role="application" aria-label="튕김 각속도 실험판" tabindex="0">
<canvas class="fw__cv" id="cv"></canvas>
</div>
<div class="fw__side">
<b class="fw__vel" id="vel">0.0</b>
<span class="fw__unit">rad/s 손을 뗀 순간의 값이 초기 속도</span>
<div class="fw__bar" id="bar" aria-hidden="true"></div>
<p class="fw__meta">튕김 세기 <b class="fw__count" id="pow">0</b><span class="fw__metaUnit">단계</span></p>
<p class="fw__meta">가장자리를 드래그해 돌리고 놓아 보세요</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 튕김 각속도(02) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 무대는 노랑 무대의 파스텔 변형 — 무대 노랑 토큰(채도 1.0) 대신 같은 색상의 연한
// 버터 톤(채도 .48 · 명도 .80)을 쓴다(BD26). 칸 다섯 색도 채도 .40 이하 파스텔.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage(#f2e4bd);
.fw {
--w1: #b3bfd4; // 파랑빛 파스텔 — 1번 칸
--w2: #d8a3b4; // 로즈 파스텔 — 2번 칸
--w3: #a9c4b0; // 세이지 파스텔 — 3번 칸
--w4: #d9c48f; // 골드 파스텔 — 4번 칸
--w5: #c2a8d1; // 플럼 파스텔 — 5번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.fw__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.fw__row { display: flex; gap: $sp-2; align-items: stretch; }
.fw__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;
}
.fw__field:active { cursor: grabbing; }
.fw__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fw__cv { display: block; width: 100%; height: 100%; }
.fw__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.fw__vel {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-blue-deep; font-variant-numeric: tabular-nums;
}
.fw__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.fw__bar { display: flex; gap: $sp-1; margin-top: $sp-1; }
.fw__seg {
width: clamp(12px, 3.4vw, 20px); height: 12px;
border-radius: $r-xs;
background: rgba($subject-ink, .14);
}
.fw__seg.is-on { background: $subject-blue-deep; }
.fw__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
.fw__meta b { font-weight: 700; color: $subject-blue-deep; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.fw__metaUnit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.fw__hint { font-size: 8px; line-height: 1.3; }
.fw { gap: $sp-1; }
.fw__field { width: 150px; height: 150px; }
.fw__vel { font-size: 16px; }
.fw__meta { margin-top: 0; font-size: 8px; }
.fw__seg { width: 10px; height: 8px; }
}
// reduce — 루프가 안 돈다. 자취 표시가 남은 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.fw.is-still .fw__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f2e4bd;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fw {
--w1: #b3bfd4;
--w2: #d8a3b4;
--w3: #a9c4b0;
--w4: #d9c48f;
--w5: #c2a8d1;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.fw__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.fw__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.fw__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;
}
.fw__field:active {
cursor: grabbing;
}
.fw__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fw__cv {
display: block;
width: 100%;
height: 100%;
}
.fw__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.fw__vel {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
}
.fw__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.fw__bar {
display: flex;
gap: 4px;
margin-top: 4px;
}
.fw__seg {
width: clamp(12px, 3.4vw, 20px);
height: 12px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.fw__seg.is-on {
background: #0a4fe8;
}
.fw__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
.fw__meta b {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.fw__metaUnit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.fw__hint {
font-size: 8px;
line-height: 1.3;
}
.fw {
gap: 4px;
}
.fw__field {
width: 150px;
height: 150px;
}
.fw__vel {
font-size: 16px;
}
.fw__meta {
margin-top: 0;
font-size: 8px;
}
.fw__seg {
width: 10px;
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.fw.is-still .fw__field {
cursor: default !important;
}
}// 튕김 각속도(02) — 포인터 이동을 회전 속도로 바꾼다. pointermove 마다 중심에서의 각도를
// Math.atan2 로 재고 이전 각도와의 차에 경과 시간을 나누면 rad/s 각속도가 나온다.
// 손을 뗀 순간의 값이 초기 회전 속도가 되어 튕긴 세기 그대로 판이 돈다.
(function () {
var root = document.getElementById('fw');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var velEl = document.getElementById('vel');
var barEl = document.getElementById('bar');
var powEl = document.getElementById('pow');
if (!root || !field || !cv || !velEl || !barEl || !powEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 5; 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 = 5, SEG = TAU / N;
var ang = -1.1, vel = 0;
var FRICTION = 0.26, STOP = 0.5;
var FLICKS = [10.2, -15.8, 6.8]; // 자동 시연 튕김 — 고정값이라 항상 같은 시연이 반복된다
var fi = 1, markAng = -1.1, markOp = 0, idle = 0, dpr = 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(peak) {
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
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.save();
ctx.rotate(a0 + SEG / 2);
ctx.fillStyle = INK;
ctx.font = '700 13px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(String(k + 1), R - 14, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
ctx.restore();
// 12시 고정 포인터
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 8); ctx.lineTo(cx + 7, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath();
ctx.fillStyle = INK; ctx.fill();
// 손 뗀 순간의 각도 자취 — 튕김의 시작점을 허브에서 표시한다
if (markOp > 0) {
ctx.beginPath();
ctx.arc(cx, cy, 20, markAng - 0.5, markAng + 0.5);
ctx.strokeStyle = 'rgba(23,20,26,' + markOp * 0.7 + ')';
ctx.lineWidth = 4; ctx.stroke();
}
}
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;
velEl.textContent = (drag.v).toFixed(1);
idle = 1.2;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-24, Math.min(24, drag.v));
markAng = ang; markOp = 1;
powEl.textContent = String(Math.min(5, Math.max(1, Math.ceil(Math.abs(vel) / 4.8))));
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
vel = FLICKS[fi++ % FLICKS.length];
markAng = ang; markOp = 1; idle = 0;
powEl.textContent = String(Math.min(5, Math.max(1, Math.ceil(Math.abs(vel) / 4.8))));
}
});
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 (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
velEl.textContent = vel.toFixed(1);
if (Math.abs(vel) < STOP) { vel = 0; velEl.textContent = '0.0'; }
} else if (idle > 0) { idle -= dt; }
else {
vel = FLICKS[fi++ % FLICKS.length];
markAng = ang; markOp = 1;
powEl.textContent = String(Math.min(5, Math.max(1, Math.ceil(Math.abs(vel) / 4.8))));
}
}
if (markOp > 0) markOp = Math.max(0, markOp - dt * 0.9);
draw(markOp);
// 속도 바 — 5단계로 채운다 (레이아웃이 아니라 transform 이다)
var lv = Math.min(5, Math.ceil(Math.abs(drag.on ? drag.v : vel) / 4.8));
for (var q = 0; q < barEl.children.length; q++) {
barEl.children[q].className = q < lv ? 'fw__seg is-on' : 'fw__seg';
}
requestAnimationFrame(step);
}
fit();
for (var s = 0; s < 5; s++) {
var seg = document.createElement('span');
seg.className = 'fw__seg';
barEl.appendChild(seg);
}
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 자취 표시가 진행 중인 상태로 고정하고 루프는 돌지 않는다
markOp = 0.5;
draw(0.5);
for (var z = 0; z < barEl.children.length; z++) barEl.children[z].className = z < 2 ? 'fw__seg is-on' : 'fw__seg';
root.classList.add('is-still');
} else {
vel = FLICKS[0]; markAng = ang; markOp = 1;
powEl.textContent = '2';
requestAnimationFrame(step);
}
})();// SpinWheelFlickVelocity — vanilla 판(run/332/demo/02-spin-wheel-flick-velocity/index.html) 의 React 포트.
// pointermove 마다 중심에서의 각도를 Math.atan2 로 재고 이전 각도와의 차에 경과 시간을 나누면
// rad/s 각속도가 나온다. 손을 뗀 순간의 값이 초기 회전 속도가 되어 튕긴 세기 그대로 판이 돈다.
// 자동 시연 튕김(FLICKS)은 시드 없는 고정값 — 항상 같은 계열이 반복된다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 자취 표시가 남은 정지 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelFlickVelocity.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelFlickVelocityProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const N = 5, SEG = TAU / N;
const FRICTION = 0.26, STOP = 0.5;
const FLICKS = [10.2, -15.8, 6.8]; // 자동 시연 튕김 — 고정값이라 항상 같은 시연이 반복된다
const PAL = ["#b3bfd4", "#d8a3b4", "#a9c4b0", "#d9c48f", "#c2a8d1"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "pointermove 마다 atan2 로 중심각을 재고 이전 각도·시간과의 차로 rad/s 를 만듭니다";
const ARIA_FIELD = "튕김 각속도 실험판";
const UNIT = "rad/s 손을 뗀 순간의 값이 초기 속도";
const POW_LABEL = "튕김 세기 ";
const POW_UNIT = "단계";
const META_DRAG = "가장자리를 드래그해 돌리고 놓아 보세요";
export default function SpinWheelFlickVelocity({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: SpinWheelFlickVelocityProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const velRef = useRef<HTMLSpanElement | null>(null);
const powRef = useRef<HTMLSpanElement | null>(null);
const barRef = useRef<HTMLDivElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
ang: -1.1, vel: 0, fi: 1, markAng: -1.1, markOp: 0, idle: 0, dpr: 1,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; fi: number; markAng: number; markOp: number; idle: 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 powLevel(v: number): number {
return Math.min(5, Math.max(1, Math.ceil(Math.abs(v) / 4.8)));
}
function draw(peak: number): 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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
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.save();
ctx.rotate(a0 + SEG / 2);
ctx.fillStyle = INK;
ctx.font = "700 13px sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText(String(k + 1), R - 14, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
ctx.restore();
// 12시 고정 포인터
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 8); ctx.lineTo(cx + 7, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath();
ctx.fillStyle = INK; ctx.fill();
// 손 뗀 순간의 각도 자취 — 튕김의 시작점을 허브에서 표시한다
if (peak > 0) {
ctx.beginPath();
ctx.arc(cx, cy, 20, st.markAng - 0.5, st.markAng + 0.5);
ctx.strokeStyle = "rgba(23,20,26," + peak * 0.7 + ")";
ctx.lineWidth = 4; ctx.stroke();
}
}
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;
const vel = velRef.current;
if (vel) vel.textContent = st.drag.v.toFixed(1);
st.idle = 1.2;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-24, Math.min(24, st.drag.v));
st.markAng = st.ang; st.markOp = 1;
const pow = powRef.current;
if (pow) pow.textContent = String(powLevel(st.vel));
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length];
st.markAng = st.ang; st.markOp = 1; st.idle = 0;
const pow = powRef.current;
if (pow) pow.textContent = String(powLevel(st.vel));
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
const vel = velRef.current, pow = powRef.current;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
if (vel) vel.textContent = st.vel.toFixed(1);
if (Math.abs(st.vel) < STOP) { st.vel = 0; if (vel) vel.textContent = "0.0"; }
} else if (st.idle > 0) { st.idle -= dt; }
else {
st.vel = FLICKS[st.fi++ % FLICKS.length];
st.markAng = st.ang; st.markOp = 1;
if (pow) pow.textContent = String(powLevel(st.vel));
}
}
if (st.markOp > 0) st.markOp = Math.max(0, st.markOp - dt * 0.9);
draw(st.markOp);
// 속도 바 — 5단계로 채운다 (레이아웃이 아니라 transform 이다)
const lv = Math.min(5, Math.ceil(Math.abs(st.drag.on ? st.drag.v : st.vel) / 4.8));
const bar = barRef.current;
if (bar) {
for (let q = 0; q < bar.children.length; q++) {
(bar.children[q] as HTMLElement).className = q < lv ? styles.seg + " " + styles.isOn : styles.seg;
}
}
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 자취 표시가 진행 중인 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
st.markOp = 0.5;
draw(0.5);
const bar = barRef.current;
if (bar) {
for (let z = 0; z < bar.children.length; z++) {
(bar.children[z] as HTMLElement).className = z < 2 ? styles.seg + " " + styles.isOn : styles.seg;
}
}
setStill(true);
} else {
const st = stRef.current;
st.vel = FLICKS[0]; st.markAng = st.ang; st.markOp = 1;
const pow = powRef.current;
if (pow) pow.textContent = "2";
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.fw + " " + styles.isStill : styles.fw;
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.vel} ref={velRef}>0.0</b>
<span className={styles.unit}>{UNIT}</span>
<div className={styles.bar} ref={barRef} aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => <span key={i} className={styles.seg}></span>)}
</div>
<p className={styles.meta}>{POW_LABEL}<b className={styles.count} ref={powRef}>0</b><span className={styles.metaUnit}>{POW_UNIT}</span></p>
<p className={styles.meta}>{META_DRAG}</p>
</div>
</div>
</div>
</main>
);
}02 · 튕김 각속도 — 포인터 이동을 회전 속도로 바꾸기 — 측정 누적면적 23.217% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
각도 차를 각속도로 — EMA 0.65/0.35
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;
velEl.textContent = (drag.v).toFixed(1);
idle = 1.2;
});
dA > Math.PI 검사 두 줄이 ±π 를 넘는 경계(예: −179° → +179°)에서 한 바퀴 오차가 생기는 것을 TAU 보정으로 막으며, 0.65/0.35 비율이 순간 노이즈를 누르고도 방향 전환을 놓치지 않는 절충입니다.
03마찰 감쇠 — 매 프레임 적분해 스스로 멈추는 판
requestAnimationFrame 루프에서 dt 를 재고 각도 += 각속도·dt, 각속도 *= Math.pow(마찰계수, dt) 를 적분하면 튕김 직후엔 빠르다가 지수곡선으로 느려집니다. 우측 그래프가 그 적분을 실시간으로 그려 판의 각속도와 그래프 기울기가 같음을 눈으로 확인할 수 있고, 마찰 계수 3단계(0.08·0.18·0.38) 버튼으로 같은 튕김(16 rad/s)의 감속 곡선을 비교합니다.

<main class="stage">
<div class="fr" id="fr">
<p class="fr__hint">rAF 루프에서 dt 를 재고 각도 += 각속도·dt, 각속도 ×= 마찰^dt 를 적분합니다</p>
<div class="fr__row">
<div class="fr__field" id="field" role="application" aria-label="마찰 감쇠 실험판" 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">마찰 계수 — 매초 각속도에 곱한다</span>
<div class="fr__sw" id="sw" role="group" aria-label="마찰 계수 고르기"></div>
<p class="fr__meta">정지까지 <b class="fr__count" id="sec">0.0</b><span class="fr__metaUnit">초 소요</span></p>
<p class="fr__meta">같은 튕김(16 rad/s)으로 계수만 바뀝니다</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]; // 낮음·보통·높음 — 3단계 버튼
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 · 마찰 감쇠 — 매 프레임 적분해 스스로 멈추는 판 — 측정 누적면적 9.394% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
적분의 두 줄과 정지 판정
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) });
}
Math.min(64, now - last) 가 탭 전환으로 프레임이 끊겨도 dt 가 64ms 를 넘지 않게 막으며, 정지는 프레임 수가 아니라 각속도 절대값이 STOP 아래로 떨어지는 순간 판정됩니다.
04가중치 확률 — 칸 각도를 무게로 나눠 공정하게 뽑기
모든 칸을 같은 각도로 그리면 칸 수만큼 균등 확률이지만, 항목마다 무게 W=[5,3,2,1,3,2] 를 두면 시작각·끝각을 누적 비율로 잡아 각도 자체가 확률이 됩니다. 자동 시연은 mulberry32 시드 332004 고정 난수로 튕김 세기를 흔들어 뽑아도 매번 같은 순서로 재현합니다.

<main class="stage">
<div class="wt" id="wt">
<p class="wt__hint">칸의 각도 자체가 확률입니다 — 무게를 누적 비율로 나눠 시작각·끝각을 잡습니다</p>
<div class="wt__row">
<div class="wt__field" id="field" role="application" aria-label="가중치 확률 실험판" tabindex="0">
<canvas class="wt__cv" id="cv"></canvas>
</div>
<div class="wt__side">
<b class="wt__num" id="odds">0%</b>
<span class="wt__unit">포인터 칸의 기대 확률</span>
<p class="wt__meta">시행 <b class="wt__count" id="tries">0</b><span class="wt__metaUnit">회</span></p>
<p class="wt__meta">시드 고정 mulberry32 — 돌릴 때마다 같은 계열의 결과</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 가중치 확률(04) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 페이퍼 무대. 칸 네 색은 파스텔 팔레트(채도 .19~.49) — 무게가 큰 칸이 넓게 보이므로
// 색 순환으로 이웃 칸이 같은 색으로 붙지 않게 한다. BD26 vivid 기준 전부 통과.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.wt {
--w1: #b3bfd4; // 파랑빛 파스텔 — 큰 칸
--w2: #d9c48f; // 골드 파스텔
--w3: #a9c4b0; // 세이지 파스텔
--w4: #d8a3b4; // 로즈 파스텔
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.wt__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.wt__row { display: flex; gap: $sp-2; align-items: stretch; }
.wt__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;
}
.wt__field:active { cursor: grabbing; }
.wt__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.wt__cv { display: block; width: 100%; height: 100%; }
.wt__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.wt__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-blue-deep; font-variant-numeric: tabular-nums;
}
.wt__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.wt__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
.wt__meta b { font-weight: 700; color: $subject-blue-deep; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.wt__metaUnit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.wt__hint { font-size: 8px; line-height: 1.3; }
.wt { gap: $sp-1; }
.wt__field { width: 150px; height: 150px; }
.wt__num { font-size: 16px; }
.wt__meta { margin-top: 0; font-size: 8px; }
}
// reduce — 루프가 안 돈다. 펄스 링이 퍼진 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.wt.is-still .wt__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wt {
--w1: #b3bfd4;
--w2: #d9c48f;
--w3: #a9c4b0;
--w4: #d8a3b4;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.wt__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.wt__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.wt__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;
}
.wt__field:active {
cursor: grabbing;
}
.wt__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.wt__cv {
display: block;
width: 100%;
height: 100%;
}
.wt__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.wt__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
}
.wt__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.wt__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
.wt__meta b {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.wt__metaUnit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.wt__hint {
font-size: 8px;
line-height: 1.3;
}
.wt {
gap: 4px;
}
.wt__field {
width: 150px;
height: 150px;
}
.wt__num {
font-size: 16px;
}
.wt__meta {
margin-top: 0;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.wt.is-still .wt__field {
cursor: default !important;
}
}// 가중치 확률(04) — 모든 칸을 같은 각도로 그리면 칸 수만큼 균등 확률이 되지만,
// 항목마다 무게를 두면 시작각·끝각을 누적 비율로 잡아 각도 자체가 확률이 된다.
// 결과는 멈춘 뒤 각도로 판정하므로 조작 없이 눈에 보이는 확률 그대로 당첨된다.
// 난수는 mulberry32 로 시드를 고정한다 — 데모 재현성.
(function () {
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rnd = mulberry32(332004);
var root = document.getElementById('wt');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var oddsEl = document.getElementById('odds');
var triesEl = document.getElementById('tries');
if (!root || !field || !cv || !oddsEl || !triesEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 4; 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 W = [5, 3, 2, 1, 3, 2];
var TOT = W.reduce(function (a, b) { return a + b; }, 0);
var bounds = [], acc = 0;
for (var q = 0; q < W.length; q++) { bounds.push(acc / TOT); acc += W[q]; }
bounds.push(1);
var ang = -0.9, vel = 0, tries = 0;
var FRICTION = 0.24, STOP = 0.5;
var settle = -1, pulse = -1, dpr = 1, lastIdx = -1;
var flicks = [11, -8, 13];
var fi = 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 norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
// 정지 각도 → 부채꼴 인덱스 (12시 고정 포인터)
function idxAt(a) {
var u = norm(-Math.PI / 2 - a);
for (var k = 0; k < W.length; k++) if (u >= bounds[k] && u < bounds[k + 1]) return k;
return W.length - 1;
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(ang);
for (var k = 0; k < W.length; k++) {
var a0 = bounds[k] * TAU, a1 = bounds[k + 1] * TAU;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1); ctx.closePath();
ctx.fillStyle = PAL[k % PAL.length]; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.20)'; ctx.lineWidth = 1; ctx.stroke();
// 무게 라벨 — 칸 가운데 각도에 확률 % 를 적는다
var mid = (a0 + a1) / 2;
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
ctx.font = '700 12px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(Math.round(W[k] / TOT * 100) + '%', R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 13 + pulse * 22, 0, TAU);
ctx.strokeStyle = 'rgba(23,20,26,' + (1 - pulse) * 0.5 + ')';
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 8); ctx.lineTo(cx + 7, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
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;
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
// 손으로 뗀 스픈은 Math.random 가감 — 자동 루프(시드 고정)와 달리 사람 손에 온도를 준다
vel = Math.max(-24, Math.min(24, drag.v * (0.85 + Math.random() * 0.3)));
if (Math.abs(vel) < 1) vel = flicks[0];
settle = -1; pulse = -1;
tries++; triesEl.textContent = String(tries);
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
vel = flicks[fi++ % flicks.length];
settle = -1; pulse = -1;
tries++; triesEl.textContent = String(tries);
}
});
var idle = 0;
function auto(dt) {
if (idle > 0) { idle -= dt; return; }
if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 0.4) {
vel = flicks[Math.floor(rnd() * flicks.length)] * (0.8 + rnd() * 0.5);
fi++;
settle = -1; pulse = -1;
tries++; triesEl.textContent = String(tries);
}
return;
}
}
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 (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; }
} else { auto(dt); }
}
draw();
var idx = idxAt(ang);
if (idx !== lastIdx) {
oddsEl.textContent = Math.round(W[idx] / TOT * 100) + '%';
lastIdx = idx;
}
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 펄스 링이 퍼진 상태로 고정하고 루프는 돌지 않는다
pulse = 0.4;
draw();
oddsEl.textContent = Math.round(W[idxAt(ang)] / TOT * 100) + '%';
root.classList.add('is-still');
} else {
vel = flicks[0];
tries = 1; triesEl.textContent = '1';
requestAnimationFrame(step);
}
})();// SpinWheelWeightedOdds — vanilla 판(run/332/demo/04-spin-wheel-weighted-odds/index.html) 의 React 포트.
// 모든 칸을 같은 각도로 그리면 균등 확률이지만, 무게(W)를 누적 비율로 시작각·끝각에 반영하면
// 각도 자체가 확률이 된다. 자동 루프는 mulberry32(332004) 로 시드 고정 — 항상 같은 결과 계열.
// 손으로 튕길 때만 Math.random 온도(0.85~1.15배)를 더해 자동 데모와 결을 다르게 한다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelWeightedOdds.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelWeightedOddsProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const TAU = Math.PI * 2;
const W = [5, 3, 2, 1, 3, 2];
const TOT = W.reduce((a, b) => a + b, 0);
const FRICTION = 0.24, STOP = 0.5;
const FLICKS = [11, -8, 13];
const PAL = ["#b3bfd4", "#d9c48f", "#a9c4b0", "#d8a3b4"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "칸의 각도 자체가 확률입니다 — 무게를 누적 비율로 나눠 시작각·끝각을 잡습니다";
const ARIA_FIELD = "가중치 확률 실험판";
const UNIT = "포인터 칸의 기대 확률";
const META_TRIES = "시행 ";
const META_TRIES_UNIT = "회";
const META_SEED = "시드 고정 mulberry32 — 돌릴 때마다 같은 계열의 결과";
function computeBounds(): number[] {
const bounds: number[] = [];
let acc = 0;
for (let q = 0; q < W.length; q++) { bounds.push(acc / TOT); acc += W[q]; }
bounds.push(1);
return bounds;
}
const BOUNDS = computeBounds();
export default function SpinWheelWeightedOdds({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: SpinWheelWeightedOddsProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const oddsRef = useRef<HTMLSpanElement | null>(null);
const triesRef = useRef<HTMLSpanElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const rndRef = useRef(mulberry32(332004)) as Box<() => number>;
const stRef = useRef({
ang: -0.9, vel: 0, tries: 0, settle: -1, pulse: -1, dpr: 1, lastIdx: -1, fi: 1, idle: 0,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; tries: number; settle: number; pulse: number; dpr: number; lastIdx: number; fi: number; idle: 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 norm(a: number): number {
const r = a % TAU;
return r < 0 ? r + TAU : r;
}
function idxAt(a: number): number {
const u = norm(-Math.PI / 2 - a);
for (let k = 0; k < W.length; k++) if (u >= BOUNDS[k] && u < BOUNDS[k + 1]) return k;
return W.length - 1;
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(st.ang);
for (let k = 0; k < W.length; k++) {
const a0 = BOUNDS[k] * TAU, a1 = BOUNDS[k + 1] * TAU;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1); ctx.closePath();
ctx.fillStyle = PAL[k % PAL.length]; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.20)"; ctx.lineWidth = 1; ctx.stroke();
const mid = (a0 + a1) / 2;
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
ctx.font = "700 12px sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText(Math.round(W[k] / TOT * 100) + "%", R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
if (st.pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 13 + st.pulse * 22, 0, TAU);
ctx.strokeStyle = "rgba(23,20,26," + (1 - st.pulse) * 0.5 + ")";
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 8); ctx.lineTo(cx + 7, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
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;
st.idle = 0;
}
function bumpTries(): void {
const st = stRef.current;
st.tries++;
const tr = triesRef.current;
if (tr) tr.textContent = String(st.tries);
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
// 손으로 뗀 스핀은 Math.random 가감 — 자동 루프(시드 고정)와 달리 사람 손에 온도를 준다
st.vel = Math.max(-24, Math.min(24, st.drag.v * (0.85 + Math.random() * 0.3)));
if (Math.abs(st.vel) < 1) st.vel = FLICKS[0];
st.settle = -1; st.pulse = -1;
bumpTries();
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length];
st.settle = -1; st.pulse = -1;
bumpTries();
}
}
function auto(dt: number): void {
const st = stRef.current;
const rnd = rndRef.current;
if (st.idle > 0) { st.idle -= dt; return; }
if (st.settle >= 0) {
st.settle += dt; st.pulse = Math.min(1, st.settle / 0.3);
if (st.settle > 0.4) {
st.vel = FLICKS[Math.floor(rnd() * FLICKS.length)] * (0.8 + rnd() * 0.5);
st.fi++;
st.settle = -1; st.pulse = -1;
bumpTries();
}
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; }
} else { auto(dt); }
}
draw();
const idx = idxAt(st.ang);
if (idx !== st.lastIdx) {
const odds = oddsRef.current;
if (odds) odds.textContent = Math.round(W[idx] / TOT * 100) + "%";
st.lastIdx = idx;
}
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 펄스 링이 퍼진 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
st.pulse = 0.4;
draw();
const odds = oddsRef.current;
if (odds) odds.textContent = Math.round(W[idxAt(st.ang)] / TOT * 100) + "%";
setStill(true);
} else {
const st = stRef.current;
st.vel = FLICKS[0]; st.tries = 1;
const tr = triesRef.current;
if (tr) tr.textContent = "1";
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.wt + " " + styles.isStill : styles.wt;
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} ref={oddsRef}>0%</b>
<span className={styles.unit}>{UNIT}</span>
<p className={styles.meta}>{META_TRIES}<b className={styles.count} ref={triesRef}>0</b><span className={styles.metaUnit}>{META_TRIES_UNIT}</span></p>
<p className={styles.meta}>{META_SEED}</p>
</div>
</div>
</div>
</main>
);
}04 · 가중치 확률 — 칸 각도를 무게로 나눠 공정하게 뽑기 — 측정 누적면적 22.052% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
bounds 배열 — 무게를 각도 경계로 굽고 그 경계로 되짚어 읽는다
var W = [5, 3, 2, 1, 3, 2];
var TOT = W.reduce(function (a, b) { return a + b; }, 0);
var bounds = [], acc = 0;
for (var q = 0; q < W.length; q++) { bounds.push(acc / TOT); acc += W[q]; }
bounds.push(1);
function norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) {
var u = norm(-Math.PI / 2 - a);
for (var k = 0; k < W.length; k++) if (u >= bounds[k] && u < bounds[k + 1]) return k;
return W.length - 1;
}
누적 비율 0 → 5/16 → 8/16 → 10/16 → 11/16 → 14/16 → 1 이 각 칸의 시작각·끝각이 되므로 화면에 보이는 부채꼴 넓이가 곧 당첨 확률이고, idxAt 은 정지 각도가 그 비율 구간 어디에 떨어지는지 순서대로 찾아 인덱스를 돌려줄 뿐이니 결과를 조작할 자리가 없습니다.
05포인터 딸깍 — 칸 경계를 지날 때마다 튕기는 소리와 흔들림
회전 각도를 칸 경계 각도(SEG)로 나눈 몫이 바뀌는 프레임을 tickCheck 로 감지하면 포인터가 핀을 지나간 순간이고, 그 프레임에 OscillatorNode 삼각파 1750Hz 짧은 틱이 납니다. 포인터 변위는 스프링 적분(a = −pk·x − pc·v, pk=340·pc=14)으로 튕겼다 되돌아오며, 소리는 사람이 직접 돌릴 때만 재생됩니다.

<main class="stage">
<div class="tk" id="tk">
<p class="tk__hint">칸 경계를 지날 때마다 포인터가 튕기고 되돌아옵니다 — 몫이 바뀌는 프레임이 경계 통과다</p>
<div class="tk__row">
<div class="tk__field" id="field" role="application" aria-label="틱 물리 실험판" tabindex="0">
<canvas class="tk__cv" id="cv"></canvas>
</div>
<div class="tk__side">
<b class="tk__num" id="ticks">0</b>
<span class="tk__unit">핀 통과 — 경계를 지난 횟수</span>
<div class="tk__bar" id="bar" aria-hidden="true"></div>
<p class="tk__meta">진폭 4px 스프링 감쇠 — 실물 핀에 걸리는 촉각</p>
<p class="tk__meta">틱 소리는 OscillatorNode — 손으로 돌릴 때만 난다</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터 딸깍(05) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 잉크 무대. 칸 여덟 색은 파스텔 팔레트(채도 .19~.49) — 어두운 무대 위에서도
// 명도 차로 칸 경계가 읽힌다. 포인터 스프링 진폭은 JS 상수 AMP 로만 논다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
.tk {
--w1: #a6b4d3; // 파랑빛 파스텔 — 1번 칸
--w2: #d9c48f; // 골드 파스텔 — 2번 칸
--w3: #a6d3b1; // 연두 파스텔 — 3번 칸
--w4: #d8a3b4; // 로즈 파스텔 — 4번 칸
--w5: #9fc6c2; // 틸 파스텔 — 5번 칸
--w6: #c2a8d1; // 플럼 파스텔 — 6번 칸
--w7: #e0c6a2; // 모래 파스텔 — 7번 칸
--w8: #a9c4b0; // 세이지 파스텔 — 8번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.tk__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.tk__row { display: flex; gap: $sp-2; align-items: stretch; }
.tk__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-cream, .04);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: grab; touch-action: none;
}
.tk__field:active { cursor: grabbing; }
.tk__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tk__cv { display: block; width: 100%; height: 100%; }
.tk__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.tk__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.tk__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .82); }
.tk__bar { display: flex; gap: $sp-1; margin-top: $sp-1; }
.tk__seg {
width: clamp(12px, 3.4vw, 20px); height: 12px;
border-radius: $r-xs;
background: rgba($subject-cream, .18);
}
.tk__seg.is-on { background: $subject-sky; }
.tk__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .82); }
@media (max-width: 340px) {
.tk__hint { font-size: 8px; line-height: 1.3; }
.tk { gap: $sp-1; }
.tk__field { width: 150px; height: 150px; }
.tk__num { font-size: 16px; }
.tk__meta { margin-top: 0; font-size: 8px; }
.tk__seg { width: 10px; height: 8px; }
}
// reduce — 루프가 안 돈다. 포인터가 튕긴 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.tk.is-still .tk__field { cursor: default !important; }
}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);
}
.tk {
--w1: #a6b4d3;
--w2: #d9c48f;
--w3: #a6d3b1;
--w4: #d8a3b4;
--w5: #9fc6c2;
--w6: #c2a8d1;
--w7: #e0c6a2;
--w8: #a9c4b0;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.tk__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.tk__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.tk__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: grab;
touch-action: none;
}
.tk__field:active {
cursor: grabbing;
}
.tk__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.tk__cv {
display: block;
width: 100%;
height: 100%;
}
.tk__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.tk__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.tk__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.82);
}
.tk__bar {
display: flex;
gap: 4px;
margin-top: 4px;
}
.tk__seg {
width: clamp(12px, 3.4vw, 20px);
height: 12px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.18);
}
.tk__seg.is-on {
background: #6ec8ff;
}
.tk__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
}
@media (max-width: 340px) {
.tk__hint {
font-size: 8px;
line-height: 1.3;
}
.tk {
gap: 4px;
}
.tk__field {
width: 150px;
height: 150px;
}
.tk__num {
font-size: 16px;
}
.tk__meta {
margin-top: 0;
font-size: 8px;
}
.tk__seg {
width: 10px;
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tk.is-still .tk__field {
cursor: default !important;
}
}// 포인터 딸깍(05) — 회전 각도를 칸 경계 각도로 나눈 몫이 바뀌는 프레임을 감지하면
// 포인터가 핀을 지나간 순간이다. 그 프레임에 포인터를 경계 방향으로 살짝 밀었다가
// 스프링 감쇠로 되돌리면 실물 핀이 걸리는 촉각이 난다. 손으로 돌릴 때는
// OscillatorNode 짧은 틱 사운드도 같이 난다(자동 루프·캡처에서는 무음)
(function () {
var root = document.getElementById('tk');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ticksEl = document.getElementById('ticks');
var barEl = document.getElementById('bar');
if (!root || !field || !cv || !ticksEl || !barEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 8; 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 = 8, SEG = TAU / N;
var ang = 0, vel = 0, ticks = 0;
var FRICTION = 0.30, STOP = 0.5;
// 포인터 스프링 — 위치 px, 속도 px/s, 스프링 상수 k, 감쇠 c. 몫이 바뀌면 속도를 얻는다
var pk = 340, pc = 14, pX = 0, pV = 0;
var AMP = 4; // 진폭 상수 (px) — 변형 3 이 값으로 논다
var flicks = [12, -9, 15];
var fi = 1, idle = 0, settle = -1, pulse = -1, dpr = 1, lastQ = 0;
// 틱 사운드 — OscillatorNode 짧은 삼각파. 사용자가 직접 돌릴 때만 소리가 난다
// (첫 pointerdown 에서 AudioContext 를 만든다 — 자동재생 정책·캡처 환경 둘 다 안전)
var ac = null;
function tickSound() {
if (!ac) return;
try {
var osc = ac.createOscillator();
var gain = ac.createGain();
osc.type = 'triangle';
osc.frequency.value = 1750;
gain.gain.setValueAtTime(0.045, ac.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + 0.03);
osc.connect(gain); gain.connect(ac.destination);
osc.start(); osc.stop(ac.currentTime + 0.035);
} catch (err) { /* 오디오 기기 없음 — 시각 틱에는 영향 없다 */ }
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
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,.22)'; ctx.lineWidth = 1; ctx.stroke();
}
// 핀 — 칸 경계마다 둥근 점
for (var p = 0; p < N; p++) {
var pa = p * SEG;
ctx.beginPath();
ctx.arc(Math.cos(pa) * (R - 4), Math.sin(pa) * (R - 4), 3, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.4)'; ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.4)'; ctx.stroke();
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 13 + pulse * 20, 0, TAU);
ctx.strokeStyle = 'rgba(255,247,230,' + (1 - pulse) * 0.7 + ')';
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
// 12시 고정 포인터 — 스프링 변위 pX 를 접선 방향(가로)으로 받는다
var tipY = cy - R + 7;
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 7);
ctx.lineTo(cx + 7, cy - R - 7);
ctx.lineTo(cx + pX, tipY);
ctx.closePath();
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
// 틱 순간 번쩍 — 핀을 지나갈 때 포인터 끝이 밝아진다
if (Math.abs(pV) > 40) {
ctx.beginPath(); ctx.arc(cx + pX, tipY, 4 + Math.min(6, Math.abs(pV) / 60), 0, TAU);
ctx.fillStyle = 'rgba(255,247,230,.5)'; ctx.fill();
}
}
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 (!ac) {
var AC = window.AudioContext || window.webkitAudioContext;
if (AC) { try { ac = new AC(); } catch (err) { ac = null; } }
}
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;
tickCheck();
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-24, Math.min(24, drag.v));
settle = -1; pulse = -1;
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
vel = flicks[fi++ % flicks.length]; settle = -1; pulse = -1; idle = 0;
}
});
// 몫이 바뀌면 핀 1개 통과 — 틱 카운트·스프링 속도를 준다
function tickCheck() {
var q = Math.floor(ang / SEG);
if (q !== lastQ) {
var d = q - lastQ;
ticks += Math.abs(d);
pV += (d > 0 ? 1 : -1) * Math.abs(vel || drag.v || 6) * 26;
if (drag.on) tickSound(); // 소리는 사람 손으로 돌릴 때만
ticksEl.textContent = String(ticks);
lastQ = q;
}
}
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;
// 포인터 스프링 적분 — a = -k·x - c·v (질량 1)
var a = -pk * pX - pc * pV;
pV += a * dt; pX += pV * dt;
if (Math.abs(pX) < 0.02 && Math.abs(pV) < 1) { pX = 0; pV = 0; }
if (!drag.on) {
if (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
tickCheck();
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; }
} else if (idle > 0) { idle -= dt; }
else if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 0.4) { vel = flicks[fi++ % flicks.length]; settle = -1; pulse = -1; }
}
}
draw();
// 진폭 바 — 스프링 변위를 5칸으로 (transform 이라 레이아웃이 흔들리지 않는다)
var lv = Math.min(5, Math.ceil(Math.abs(pX) / (AMP / 5)));
for (var b2 = 0; b2 < barEl.children.length; b2++) {
barEl.children[b2].className = b2 < lv ? 'tk__seg is-on' : 'tk__seg';
}
requestAnimationFrame(step);
}
fit();
for (var s = 0; s < 5; s++) {
var seg = document.createElement('span');
seg.className = 'tk__seg';
barEl.appendChild(seg);
}
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 포인터가 살짝 튕긴 상태로 고정하고 루프는 돌지 않는다
pX = AMP * 0.7; pulse = 0.4;
draw();
ticksEl.textContent = '17';
for (var z = 0; z < barEl.children.length; z++) barEl.children[z].className = z < 3 ? 'tk__seg is-on' : 'tk__seg';
root.classList.add('is-still');
} else {
vel = flicks[0];
requestAnimationFrame(step);
}
})();// SpinWheelPointerPeg — vanilla 판(run/332/demo/05-spin-wheel-pointer-peg/index.html) 의 React 포트.
// 회전 각도를 칸 경계 각도(SEG)로 나눈 몫이 바뀌는 프레임을 감지하면 포인터가 핀을 지나간
// 순간이다. 그 프레임에 스프링(a = -pk·pX - pc·pV)으로 포인터를 튕겼다 되돌린다. 손으로 돌릴
// 때만 첫 pointerdown 에서 지연 생성한 AudioContext 로 OscillatorNode 짧은 틱을 울린다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelPointerPeg.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelPointerPegProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const N = 8, SEG = TAU / N;
const FRICTION = 0.30, STOP = 0.5;
const PK = 340, PC = 14; // 스프링 상수 k · 감쇠 c (질량 1)
const AMP = 4; // 진폭 상수(px)
const FLICKS = [12, -9, 15];
const PAL = ["#a6b4d3", "#d9c48f", "#a6d3b1", "#d8a3b4", "#9fc6c2", "#c2a8d1", "#e0c6a2", "#a9c4b0"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "칸 경계를 지날 때마다 포인터가 튕기고 되돌아옵니다 — 몫이 바뀌는 프레임이 경계 통과다";
const ARIA_FIELD = "틱 물리 실험판";
const UNIT = "핀 통과 — 경계를 지난 횟수";
const META_AMP = "진폭 4px 스프링 감쇠 — 실물 핀에 걸리는 촉각";
const META_SOUND = "틱 소리는 OscillatorNode — 손으로 돌릴 때만 난다";
type AudioCtor = new () => AudioContext;
export default function SpinWheelPointerPeg({
duration = "2s",
easing = "ease-spring",
color = "#6ec8ff"
}: SpinWheelPointerPegProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ticksRef = useRef<HTMLSpanElement | null>(null);
const barRef = useRef<HTMLDivElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const acRef = useRef<AudioContext | null>(null) as Box<AudioContext | null>;
const stRef = useRef({
ang: 0, vel: 0, ticks: 0, pX: 0, pV: 0,
fi: 1, idle: 0, settle: -1, pulse: -1, dpr: 1, lastQ: 0,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; ticks: number; pX: number; pV: number;
fi: number; idle: number; settle: number; pulse: number; dpr: number; lastQ: 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 tickSound(): void {
const ac = acRef.current;
if (!ac) return;
try {
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = "triangle";
osc.frequency.value = 1750;
gain.gain.setValueAtTime(0.045, ac.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + 0.03);
osc.connect(gain); gain.connect(ac.destination);
osc.start(); osc.stop(ac.currentTime + 0.035);
} catch { /* 오디오 기기 없음 — 시각 틱에는 영향 없다 */ }
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
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,.22)"; ctx.lineWidth = 1; ctx.stroke();
}
for (let p = 0; p < N; p++) {
const pa = p * SEG;
ctx.beginPath();
ctx.arc(Math.cos(pa) * (R - 4), Math.sin(pa) * (R - 4), 3, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.4)"; ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.4)"; ctx.stroke();
if (st.pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 13 + st.pulse * 20, 0, TAU);
ctx.strokeStyle = "rgba(255,247,230," + (1 - st.pulse) * 0.7 + ")";
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
const tipY = cy - R + 7;
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 7);
ctx.lineTo(cx + 7, cy - R - 7);
ctx.lineTo(cx + st.pX, tipY);
ctx.closePath();
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
if (Math.abs(st.pV) > 40) {
ctx.beginPath(); ctx.arc(cx + st.pX, tipY, 4 + Math.min(6, Math.abs(st.pV) / 60), 0, TAU);
ctx.fillStyle = "rgba(255,247,230,.5)"; ctx.fill();
}
}
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 tickCheck(): void {
const st = stRef.current;
const q = Math.floor(st.ang / SEG);
if (q !== st.lastQ) {
const d = q - st.lastQ;
st.ticks += Math.abs(d);
st.pV += (d > 0 ? 1 : -1) * Math.abs(st.vel || st.drag.v || 6) * 26;
if (st.drag.on) tickSound(); // 소리는 사람 손으로 돌릴 때만
const tk = ticksRef.current;
if (tk) tk.textContent = String(st.ticks);
st.lastQ = q;
}
}
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;
if (!acRef.current) {
const w = window as unknown as { AudioContext?: AudioCtor; webkitAudioContext?: AudioCtor };
const Ctor = w.AudioContext || w.webkitAudioContext;
if (Ctor) { try { acRef.current = new Ctor(); } catch { acRef.current = null; } }
}
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;
tickCheck();
st.idle = 0;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-24, Math.min(24, st.drag.v));
st.settle = -1; st.pulse = -1;
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; st.idle = 0;
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
// 포인터 스프링 적분 — a = -k·x - c·v (질량 1)
const a = -PK * st.pX - PC * st.pV;
st.pV += a * dt; st.pX += st.pV * dt;
if (Math.abs(st.pX) < 0.02 && Math.abs(st.pV) < 1) { st.pX = 0; st.pV = 0; }
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
tickCheck();
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; }
} else if (st.idle > 0) { st.idle -= dt; }
else if (st.settle >= 0) {
st.settle += dt; st.pulse = Math.min(1, st.settle / 0.3);
if (st.settle > 0.4) { st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; }
}
}
draw();
const lv = Math.min(5, Math.ceil(Math.abs(st.pX) / (AMP / 5)));
const bar = barRef.current;
if (bar) {
for (let b2 = 0; b2 < bar.children.length; b2++) {
(bar.children[b2] as HTMLElement).className = b2 < lv ? styles.seg + " " + styles.isOn : styles.seg;
}
}
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 포인터가 살짝 튕긴 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
st.pX = AMP * 0.7; st.pulse = 0.4;
draw();
const tk = ticksRef.current;
if (tk) tk.textContent = "17";
const bar = barRef.current;
if (bar) {
for (let z = 0; z < bar.children.length; z++) {
(bar.children[z] as HTMLElement).className = z < 3 ? styles.seg + " " + styles.isOn : styles.seg;
}
}
setStill(true);
} else {
stRef.current.vel = FLICKS[0];
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.tk + " " + styles.isStill : styles.tk;
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} ref={ticksRef}>0</b>
<span className={styles.unit}>{UNIT}</span>
<div className={styles.bar} ref={barRef} aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => <span key={i} className={styles.seg}></span>)}
</div>
<p className={styles.meta}>{META_AMP}</p>
<p className={styles.meta}>{META_SOUND}</p>
</div>
</div>
</div>
</main>
);
}05 · 포인터 딸깍 — 칸 경계를 지날 때마다 튕기는 소리와 흔들림 — 측정 누적면적 22.926% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
몫이 바뀌면 핀 1개 통과 — 틱과 스프링 속도
function tickCheck() {
var q = Math.floor(ang / SEG);
if (q !== lastQ) {
var d = q - lastQ;
ticks += Math.abs(d);
pV += (d > 0 ? 1 : -1) * Math.abs(vel || drag.v || 6) * 26;
if (drag.on) tickSound(); // 소리는 사람 손으로 돌릴 때만
ticksEl.textContent = String(ticks);
lastQ = q;
}
}
drag.on 조건이 없으면 자동 반복 시연과 gif·mp4 녹화에도 소리가 실리는데 무음 매체가 소리 있는 척하는 어색한 결과가 되므로, tickSound 는 사람 손이 판을 붙잡고 있는 동안에만 불립니다.
06당첨 판정 — 정지 각도를 부채꼴 인덱스로 환산하기
포인터는 12시 방향(−π/2)에 고정이므로 정지 각도를 그 기준으로 정규화한 뒤 칸 각도로 나누면 당첨 인덱스가 나옵니다. 결과는 aria-live="polite" 로 읽어주고 판정·표시·재도전(round 카운터)을 한 흐름으로 묶어 화면을 켠 채로 몇 번이든 돌립니다.

<main class="stage">
<div class="jd" id="jd">
<p class="jd__hint">정지 각도를 −π/2 기준으로 정규화해 칸 각도로 나누면 당첨 인덱스가 나옵니다</p>
<div class="jd__row">
<div class="jd__field" id="field" role="application" aria-label="당첨 판정 실험판" tabindex="0">
<canvas class="jd__cv" id="cv"></canvas>
</div>
<div class="jd__side">
<b class="jd__win" id="win">-</b>
<span class="jd__unit" id="winName">판을 돌리면 결과가 여기 살아납니다</span>
<p class="jd__meta">정지 각도 <b class="jd__count" id="rest">0</b><span class="jd__metaUnit">°</span></p>
<p class="jd__meta">연속 <b class="jd__count" id="round">0</b><span class="jd__metaUnit">판째</span></p>
<p class="jd__sr" id="live" aria-live="polite"></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 당첨 판정(06) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 무대는 노랑 무대의 파스텔 변형 — 무대 노랑 토큰 대신 연한 버터 톤(채도 .49 · 명도 .78).
// 칸 여섯 색도 파스텔 — 당첨 칸은 스트로크 강조로 알린다(색이 아니라 선).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage(#f0e2ba);
.jd {
--w1: #b3bfd4; // 파랑빛 파스텔 — 1번 칸
--w2: #d8a3b4; // 로즈 파스텔 — 2번 칸
--w3: #a9c4b0; // 세이지 파스텔 — 3번 칸
--w4: #d9c48f; // 골드 파스텔 — 4번 칸
--w5: #c2a8d1; // 플럼 파스텔 — 5번 칸
--w6: #9fc6c2; // 틸 파스텔 — 6번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.jd__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.jd__row { display: flex; gap: $sp-2; align-items: stretch; }
.jd__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;
}
.jd__field:active { cursor: grabbing; }
.jd__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.jd__cv { display: block; width: 100%; height: 100%; }
.jd__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.jd__win {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-blue-deep; font-variant-numeric: tabular-nums;
}
.jd__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.jd__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
.jd__meta b { font-weight: 700; color: $subject-blue-deep; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.jd__metaUnit { color: rgba($subject-ink, .72); }
.jd__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); margin: -1px; }
@media (max-width: 340px) {
.jd__hint { font-size: 8px; line-height: 1.3; }
.jd { gap: $sp-1; }
.jd__field { width: 150px; height: 150px; }
.jd__win { font-size: 16px; }
.jd__meta { margin-top: 0; font-size: 8px; }
}
// reduce — 루프가 안 돈다. 판정까지 끝난 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.jd.is-still .jd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f0e2ba;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.jd {
--w1: #b3bfd4;
--w2: #d8a3b4;
--w3: #a9c4b0;
--w4: #d9c48f;
--w5: #c2a8d1;
--w6: #9fc6c2;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.jd__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.jd__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.jd__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;
}
.jd__field:active {
cursor: grabbing;
}
.jd__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.jd__cv {
display: block;
width: 100%;
height: 100%;
}
.jd__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.jd__win {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
}
.jd__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.jd__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
.jd__meta b {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.jd__metaUnit {
color: rgba(23, 20, 26, 0.72);
}
.jd__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
margin: -1px;
}
@media (max-width: 340px) {
.jd__hint {
font-size: 8px;
line-height: 1.3;
}
.jd {
gap: 4px;
}
.jd__field {
width: 150px;
height: 150px;
}
.jd__win {
font-size: 16px;
}
.jd__meta {
margin-top: 0;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.jd.is-still .jd__field {
cursor: default !important;
}
}// 당첨 판정(06) — 포인터는 12시 방향 고정이므로 정지 각도를 −π/2 기준으로 정규화한
// 뒤 칸 각도로 나누면 당첨 인덱스가 나온다. 결과는 aria-live 로 읽어주고, 판정·표시·
// 재도전을 한 흐름으로 묶어 화면을 켠 채로 몇 번이든 돌린다.
(function () {
var root = document.getElementById('jd');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var winEl = document.getElementById('win');
var nameEl = document.getElementById('winName');
var restEl = document.getElementById('rest');
var roundEl = document.getElementById('round');
var liveEl = document.getElementById('live');
if (!root || !field || !cv || !winEl || !nameEl || !restEl || !roundEl || !liveEl) 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 NAMES = ['커피 기프티콘', '꽝 다음 판', '삼각김밥', '쿠키', '아메리카노', '벌칙 당첨'];
var N = 6, SEG = TAU / N;
var ang = -0.7, vel = 0, round = 0, lastWin = -1;
var FRICTION = 0.28, STOP = 0.5;
var settle = -1, pulse = -1, dpr = 1, announced = false;
var flicks = [10.5, -12.5, 9];
var fi = 1, idle = 0;
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 norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) { return Math.floor(norm(-Math.PI / 2 - a) / SEG) % N; }
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 12;
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();
// 당첨 칸은 한 단계 진하게 — settle 중일때만
var isWin = settle >= 0 && k === lastWin;
ctx.fillStyle = isWin ? PAL[k] : PAL[k];
ctx.fill();
if (isWin) {
ctx.strokeStyle = INK; ctx.lineWidth = 2.5; ctx.stroke();
} else {
ctx.strokeStyle = 'rgba(23,20,26,.20)'; ctx.lineWidth = 1; ctx.stroke();
}
// 이름 — 칸 가운데 각도에 세로로 눕혀 적는다
ctx.save(); ctx.rotate(a0 + SEG / 2);
ctx.fillStyle = INK;
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(NAMES[k], R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 13 + pulse * 24, 0, TAU);
ctx.strokeStyle = 'rgba(23,20,26,' + (1 - pulse) * 0.55 + ')';
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 9); ctx.lineTo(cx + 7, cy - R - 9); ctx.lineTo(cx, cy - R + 8);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
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;
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-24, Math.min(24, drag.v));
if (Math.abs(vel) < 1.5) vel = flicks[0];
settle = -1; pulse = -1; announced = false;
round++; roundEl.textContent = String(round);
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
vel = flicks[fi++ % flicks.length];
settle = -1; pulse = -1; announced = false;
round++; roundEl.textContent = String(round);
}
});
function judge() {
var deg = Math.round(norm(ang) * 180 / Math.PI);
restEl.textContent = String(deg);
var idx = idxAt(ang);
lastWin = idx;
winEl.textContent = String(idx + 1) + '번';
nameEl.textContent = NAMES[idx];
liveEl.textContent = idx + 1 + '번 ' + NAMES[idx];
}
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 (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; judge(); }
} else if (idle > 0) { idle -= dt; }
else if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 0.85) {
vel = flicks[fi++ % flicks.length];
settle = -1; pulse = -1; announced = false;
round++; roundEl.textContent = String(round);
}
}
}
draw();
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 판정까지 끝난 상태로 고정하고 루프는 돌지 않는다
ang = -Math.PI / 2 - 2.1 * SEG;
lastWin = idxAt(ang);
pulse = 0.4; settle = 0;
draw();
judge();
roundEl.textContent = '1'; round = 1;
root.classList.add('is-still');
} else {
vel = flicks[0];
round = 1; roundEl.textContent = '1';
requestAnimationFrame(step);
}
})();// SpinWheelResultSnap — vanilla 판(run/332/demo/06-spin-wheel-result-snap/index.html) 의 React 포트.
// judge() 가 정지 각도를 -90도 기준으로 정규화해 당첨 칸 인덱스를 정하고, 그 칸만 굵은 잉크
// 테두리로 강조한다. 결과는 시각 강조와 함께 aria-live 영역에도 텍스트로 알린다(스크린리더 대응).
// 정지 뒤 0.85초 대기 후 자동 재시작 — 다른 항목보다 결과를 오래 보여준다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelResultSnap.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelResultSnapProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const N = 6, SEG = TAU / N;
const NAMES = ["커피 기프티콘", "꽝 다음 판", "삼각김밥", "쿠키", "아메리카노", "벌칙 당첨"];
const FRICTION = 0.28, STOP = 0.5;
const FLICKS = [10.5, -12.5, 9];
const PAL = ["#b3bfd4", "#d9c48f", "#a9c4b0", "#c2a8d1", "#d8a3b4", "#9fc6c2"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "정지 각도를 -90도 기준으로 정규화해 당첨 칸 인덱스를 구합니다";
const ARIA_FIELD = "결과 판정 실험판";
const UNIT = "번 칸이 당첨입니다";
const META_ROUND = "회차 ";
const META_ROUND_UNIT = "번째 판정";
const SETTLE_WAIT = 0.85; // 다른 항목보다 결과를 오래 보여준다
export default function SpinWheelResultSnap({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: SpinWheelResultSnapProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const winRef = useRef<HTMLSpanElement | null>(null);
const nameRef = useRef<HTMLSpanElement | null>(null);
const roundRef = useRef<HTMLSpanElement | null>(null);
const liveRef = useRef<HTMLDivElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
ang: -0.6, vel: 0, round: 0, settle: -1, lastWin: -1, dpr: 1, fi: 1, idle: 0,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; round: number; settle: number; lastWin: number; dpr: number; fi: number; idle: 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 norm(a: number): number {
const r = a % TAU;
return r < 0 ? r + TAU : r;
}
function judge(): void {
const st = stRef.current;
const u = norm(-Math.PI / 2 - st.ang);
const idx = Math.min(N - 1, Math.floor(u / SEG));
st.lastWin = idx;
const win = winRef.current, name = nameRef.current, live = liveRef.current;
if (win) win.textContent = String(idx + 1) + "번";
if (name) name.textContent = NAMES[idx];
if (live) live.textContent = String(idx + 1) + "번 " + NAMES[idx] + " 당첨";
}
function bumpRound(): void {
const st = stRef.current;
st.round++;
const rd = roundRef.current;
if (rd) rd.textContent = String(st.round);
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
const isSettled = st.settle >= 0;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(st.ang);
for (let k = 0; k < N; k++) {
const a0 = k * SEG;
const isWin = isSettled && k === st.lastWin;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + SEG); ctx.closePath();
ctx.fillStyle = PAL[k]; ctx.fill();
ctx.strokeStyle = isWin ? INK : "rgba(23,20,26,.20)";
ctx.lineWidth = isWin ? 2.5 : 1;
ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 8); ctx.lineTo(cx + 7, cy - R - 8); ctx.lineTo(cx, cy - R + 8);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
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;
st.idle = 0;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-24, Math.min(24, st.drag.v));
if (Math.abs(st.vel) < 1) st.vel = FLICKS[0];
st.settle = -1;
bumpRound();
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length];
st.settle = -1; st.idle = 0;
bumpRound();
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; judge(); }
} else if (st.settle >= 0) {
st.settle += dt;
if (st.settle > SETTLE_WAIT) {
st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1;
bumpRound();
}
} else if (st.idle > 0) { st.idle -= dt; }
else { st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; bumpRound(); }
}
draw();
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 판정이 끝난 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
st.settle = 0.5;
judge();
draw();
st.round = 1;
const rd = roundRef.current;
if (rd) rd.textContent = "1";
setStill(true);
} else {
stRef.current.vel = FLICKS[0];
stRef.current.round = 1;
const rd = roundRef.current;
if (rd) rd.textContent = "1";
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.jd + " " + styles.isStill : styles.jd;
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} ref={winRef}>0번</b>
<span className={styles.unit}>{UNIT}</span>
<p className={styles.name} ref={nameRef}></p>
<p className={styles.meta}>{META_ROUND}<b className={styles.count} ref={roundRef}>0</b><span className={styles.metaUnit}>{META_ROUND_UNIT}</span></p>
<div className={styles.sr} ref={liveRef} aria-live="polite"></div>
</div>
</div>
</div>
</main>
);
}06 · 당첨 판정 — 정지 각도를 부채꼴 인덱스로 환산하기 — 측정 누적면적 22.293% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
판정은 한 줄 — norm 과 나눗셈, 그리고 그 값을 읽어 알리는 judge
function norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) { return Math.floor(norm(-Math.PI / 2 - a) / SEG) % N; }
function judge() {
var deg = Math.round(norm(ang) * 180 / Math.PI);
restEl.textContent = String(deg);
var idx = idxAt(ang);
lastWin = idx;
winEl.textContent = String(idx + 1) + '번';
nameEl.textContent = NAMES[idx];
liveEl.textContent = idx + 1 + '번 ' + NAMES[idx];
}
norm 이 음수 각도를 0~TAU 로 접고 -Math.PI/2 를 빼 12시 포인터를 기준으로 삼은 뒤 칸 각도로 나눠 몫을 취하는 것이 판정 전부이며, judge 는 그 몫 하나로 화면 표시 세 곳과 liveEl(aria-live) 을 동시에 채울 뿐이니 임의로 칸을 정하면 계산이 아니라 연출이 됩니다.
07항목 편집기 — 이름 줄과 칸 각도가 함께 다시 계산되는 판
이름을 치면 measureText 로 폭을 재고 칸 호 길이(R·seg·0.78)에 맞게 글자 크기를 이분 탐색(7회 반복, 6~15px)으로 줄입니다. 항목이 늘고 줄 때마다(기본 4개, 최대 7개) 각도 분할(TAU/n)과 배치를 전부 다시 계산해 판이 편집 내용을 그대로 따라갑니다.

<main class="stage">
<div class="ed" id="ed">
<p class="ed__hint">이름을 고치면 칸 각도와 글자 크기가 함께 다시 계산됩니다 — measureText 이분 탐색</p>
<div class="ed__row">
<div class="ed__field" id="field" role="application" aria-label="항목 편집기 실험판" tabindex="0">
<canvas class="ed__cv" id="cv"></canvas>
</div>
<div class="ed__side">
<div class="ed__inputs" id="inputs"></div>
<button class="ed__btn" id="btn" type="button">칸 추가</button>
<p class="ed__meta">지금 <b class="ed__count" id="cnt">4</b><span class="ed__metaUnit">칸</span></p>
<p class="ed__meta">드래그로 돌리고 놓으면 돌아갑니다</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 항목 편집기(07) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 무대는 주황 무대의 파스텔 변형 — 무대 주황 토큰 대신 연한 피치 톤(채도 .43 · 명도 .84).
// 입력칸은 무대색보다 한 단계 밝은 크림 면 위에 잉크 글자.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage(#f2d8c8);
.ed {
--w1: #b3bfd4; // 파랑빛 파스텔 — 칸
--w2: #d9c48f; // 골드 파스텔
--w3: #a9c4b0; // 세이지 파스텔
--w4: #c2a8d1; // 플럼 파스텔
--w5: #d8a3b4; // 로즈 파스텔
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.ed__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.ed__row { display: flex; gap: $sp-2; align-items: stretch; }
.ed__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;
}
.ed__field:active { cursor: grabbing; }
.ed__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ed__cv { display: block; width: 100%; height: 100%; }
.ed__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.ed__inputs { display: flex; flex-direction: column; gap: $sp-1; }
.ed__lab { display: block; }
.ed__inp {
box-sizing: border-box; width: 100%;
border: 0; border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 600; font-size: 11px; line-height: 1.4;
font-family: inherit;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .2);
}
.ed__inp:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ed__btn {
border: 0; cursor: pointer;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: $subject-blue-deep; color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.2;
font-family: inherit;
}
.ed__btn:disabled { opacity: .45; cursor: default; }
.ed__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ed__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
.ed__meta b { font-weight: 700; color: $subject-blue-deep; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.ed__metaUnit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.ed__hint { font-size: 8px; line-height: 1.3; }
.ed { gap: $sp-1; }
.ed__field { width: 150px; height: 150px; }
.ed__meta { margin-top: 0; font-size: 8px; }
.ed__inp, .ed__btn { padding: 4px 8px; font-size: 8px; line-height: 1.1; }
.ed__inputs { gap: 0; }
.ed__side { gap: 0; }
}
// reduce — 루프가 안 돈다. 7칸으로 늘어난 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.ed.is-still .ed__field { cursor: default !important; }
.ed.is-still .ed__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);
}
.ed {
--w1: #b3bfd4;
--w2: #d9c48f;
--w3: #a9c4b0;
--w4: #c2a8d1;
--w5: #d8a3b4;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.ed__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ed__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.ed__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;
}
.ed__field:active {
cursor: grabbing;
}
.ed__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ed__cv {
display: block;
width: 100%;
height: 100%;
}
.ed__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.ed__inputs {
display: flex;
flex-direction: column;
gap: 4px;
}
.ed__lab {
display: block;
}
.ed__inp {
box-sizing: border-box;
width: 100%;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.2);
}
.ed__inp:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ed__btn {
border: 0;
cursor: pointer;
border-radius: 4px;
padding: 4px 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
font-family: inherit;
}
.ed__btn:disabled {
opacity: 0.45;
cursor: default;
}
.ed__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ed__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
.ed__meta b {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.ed__metaUnit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.ed__hint {
font-size: 8px;
line-height: 1.3;
}
.ed {
gap: 4px;
}
.ed__field {
width: 150px;
height: 150px;
}
.ed__meta {
margin-top: 0;
font-size: 8px;
}
.ed__inp, .ed__btn {
padding: 4px 8px;
font-size: 8px;
line-height: 1.1;
}
.ed__inputs {
gap: 0;
}
.ed__side {
gap: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ed.is-still .ed__field {
cursor: default !important;
}
.ed.is-still .ed__btn {
cursor: default;
}
}// 항목 편집기(07) — 이름을 치면 measureText 로 폭을 재고 칸 호 길이에 맞게 글자 크기를
// 이분 탐색으로 줄인다. 줄이 늘고 줄어들 때마다 각도 분할과 배치를 전부 다시 계산해
// 판이 편집 내용을 그대로 따라간다.
(function () {
var root = document.getElementById('ed');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var inputsEl = document.getElementById('inputs');
var btnEl = document.getElementById('btn');
var cntEl = document.getElementById('cnt');
if (!root || !field || !cv || !inputsEl || !btnEl || !cntEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 5; 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 entries = ['김하늘', '이준호', '박서연', '최도윤'];
var MAXN = 7;
var ang = -0.4, vel = 0;
var FRICTION = 0.32, STOP = 0.5;
var settle = -1, pulse = -1, dpr = 1;
var flicks = [8, -10, 6.5];
var fi = 1, idle = 0;
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 norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
// 호 길이에 맞는 글자 크기 — 이분 탐색. measureText 로 폭을 재며 경계를 좁힌다
function fitFont(text, arcLen) {
var lo = 6, hi = 15, best = 6;
for (var it = 0; it < 7; it++) {
var mid = (lo + hi) / 2;
ctx.font = '700 ' + mid + 'px sans-serif';
if (ctx.measureText(text).width <= arcLen) { best = mid; lo = mid; } else { hi = mid; }
}
return best;
}
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 n = entries.length, seg = TAU / n;
var cx = w / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(ang);
for (var k = 0; k < n; k++) {
var a0 = k * seg, a1 = a0 + seg;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1); ctx.closePath();
ctx.fillStyle = PAL[k % PAL.length]; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.20)'; ctx.lineWidth = 1; ctx.stroke();
// 이름 — 호 길이(반지름 × 중심각, 여유 .78)에 맞게 이분 탐색한 크기로 적는다
var mid = (a0 + a1) / 2;
var fs = fitFont(entries[k], R * seg * 0.78);
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
ctx.font = '700 ' + fs + 'px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(entries[k], R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 12 + pulse * 18, 0, TAU);
ctx.strokeStyle = 'rgba(23,20,26,' + (1 - pulse) * 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 + 7);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
// 입력칸 — 값이 바뀌면(change) 판이 같이 다시 그려진다
function renderInputs() {
inputsEl.textContent = '';
for (var k = 0; k < entries.length; k++) {
(function (idx) {
var lab = document.createElement('label');
lab.className = 'ed__lab';
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'ed__inp';
inp.value = entries[idx];
inp.maxLength = 8;
inp.setAttribute('aria-label', (idx + 1) + '번 항목 이름');
inp.addEventListener('change', function () {
entries[idx] = inp.value || '이름';
idle = 1.2;
draw();
});
lab.appendChild(inp);
inputsEl.appendChild(lab);
})(k);
}
cntEl.textContent = String(entries.length);
btnEl.disabled = entries.length >= MAXN;
}
btnEl.addEventListener('click', function () {
if (entries.length >= MAXN) return;
entries.push('새 항목');
renderInputs();
idle = 1.2;
draw();
});
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;
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-20, Math.min(20, drag.v));
settle = -1; pulse = -1;
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); vel = flicks[fi++ % flicks.length]; settle = -1; pulse = -1; idle = 0; }
});
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 (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; }
} else if (idle > 0) { idle -= dt; }
else if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 0.34) { vel = flicks[fi++ % flicks.length]; settle = -1; pulse = -1; }
}
}
draw();
requestAnimationFrame(step);
}
fit();
renderInputs();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 7칸까지 늘어난 상태로 고정하고 루프는 돌지 않는다
entries = ['김하늘', '이준호', '박서연', '최도윤', '정민준', '한수아', '새 항목'];
renderInputs();
pulse = 0.4; settle = 0;
draw();
root.classList.add('is-still');
} else {
vel = flicks[0];
requestAnimationFrame(step);
}
})();// SpinWheelEntryEditor — vanilla 판(run/332/demo/07-spin-wheel-entry-editor/index.html) 의 React 포트.
// 이름을 고치면 칸 수가 그대로 각도 분할에 반영되고, 각 칸 글자 크기는 fitFont() 가 호 길이에
// 맞게 measureText 이분 탐색(7회)으로 줄인다. "칸 추가"는 MAXN=7 까지만 늘린다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent, ChangeEvent } from "react";
import styles from "./SpinWheelEntryEditor.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelEntryEditorProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const MAXN = 7;
const FRICTION = 0.32, STOP = 0.5;
const FLICKS = [8, -10, 6.5];
const PAL = ["#b3bfd4", "#d9c48f", "#a9c4b0", "#c2a8d1", "#d8a3b4"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const INIT_ENTRIES = ["김하늘", "이준호", "박서연", "최도윤"];
const REDUCE_ENTRIES = ["김하늘", "이준호", "박서연", "최도윤", "정민준", "한수아", "새 항목"];
const HINT = "이름을 고치면 칸 각도와 글자 크기가 함께 다시 계산됩니다 — measureText 이분 탐색";
const ARIA_FIELD = "항목 편집기 실험판";
const META_LABEL = "지금 ";
const META_UNIT = "칸";
const META_DRAG = "드래그로 돌리고 놓으면 돌아갑니다";
const ADD_LABEL = "칸 추가";
export default function SpinWheelEntryEditor({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: SpinWheelEntryEditorProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const [entries, setEntries] = useState<string[]>(INIT_ENTRIES);
const entriesRef = useRef(INIT_ENTRIES) as Box<string[]>;
const stRef = useRef({
ang: -0.4, vel: 0, settle: -1, pulse: -1, dpr: 1, fi: 1, idle: 0,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; settle: number; pulse: number; dpr: number; fi: number; idle: 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;
}
// 호 길이에 맞는 글자 크기 — 이분 탐색. measureText 로 폭을 재며 경계를 좁힌다
function fitFont(ctx: CanvasRenderingContext2D, text: string, arcLen: number): number {
let lo = 6, hi = 15, best = 6;
for (let it = 0; it < 7; it++) {
const mid = (lo + hi) / 2;
ctx.font = "700 " + mid + "px sans-serif";
if (ctx.measureText(text).width <= arcLen) { best = mid; lo = mid; } else { hi = mid; }
}
return best;
}
function draw(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const list = entriesRef.current;
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 n = list.length, seg = TAU / n;
const cx = w / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(st.ang);
for (let k = 0; k < n; k++) {
const a0 = k * seg, a1 = a0 + seg;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1); ctx.closePath();
ctx.fillStyle = PAL[k % PAL.length]; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.20)"; ctx.lineWidth = 1; ctx.stroke();
const mid = (a0 + a1) / 2;
const fs = fitFont(ctx, list[k], R * seg * 0.78);
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
ctx.font = "700 " + fs + "px sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText(list[k], R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
if (st.pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 12 + st.pulse * 18, 0, TAU);
ctx.strokeStyle = "rgba(23,20,26," + (1 - st.pulse) * 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 + 7);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
function onEntryChange(idx: number, e: ChangeEvent<HTMLInputElement>): void {
const st = stRef.current;
const list = entriesRef.current.slice();
list[idx] = e.target.value || "이름";
entriesRef.current = list;
setEntries(list);
st.idle = 1.2;
draw();
}
function addEntry(): void {
const st = stRef.current;
if (entriesRef.current.length >= MAXN) return;
const list = entriesRef.current.concat(["새 항목"]);
entriesRef.current = list;
setEntries(list);
st.idle = 1.2;
draw();
}
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;
st.idle = 0;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-20, Math.min(20, st.drag.v));
st.settle = -1; st.pulse = -1;
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; st.idle = 0;
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; }
} else if (st.idle > 0) { st.idle -= dt; }
else if (st.settle >= 0) {
st.settle += dt; st.pulse = Math.min(1, st.settle / 0.3);
if (st.settle > 0.34) { st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; }
}
}
draw();
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 7칸까지 늘어난 상태로 고정하고 루프는 돌지 않는다
entriesRef.current = REDUCE_ENTRIES;
setEntries(REDUCE_ENTRIES);
const st = stRef.current;
st.pulse = 0.4; st.settle = 0;
draw();
setStill(true);
} else {
stRef.current.vel = FLICKS[0];
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.ed + " " + styles.isStill : styles.ed;
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}>
<div className={styles.inputs}>
{entries.map((v, idx) => (
<label key={idx} className={styles.lab}>
<input
type="text"
className={styles.inp}
defaultValue={v}
maxLength={8}
aria-label={String(idx + 1) + "번 항목 이름"}
onChange={(e: ChangeEvent<HTMLInputElement>) => onEntryChange(idx, e)}
/>
</label>
))}
</div>
<button type="button" className={styles.btn} disabled={entries.length >= MAXN} onClick={addEntry}>
{ADD_LABEL}
</button>
<p className={styles.meta}>{META_LABEL}<b className={styles.count}>{entries.length}</b><span className={styles.metaUnit}>{META_UNIT}</span></p>
<p className={styles.meta}>{META_DRAG}</p>
</div>
</div>
</div>
</main>
);
}07 · 항목 편집기 — 이름 줄과 칸 각도가 함께 다시 계산되는 판 — 측정 누적면적 21.903% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
fitFont — 7번 재면서 경계를 좁히는 이분 탐색
function fitFont(text, arcLen) {
var lo = 6, hi = 15, best = 6;
for (var it = 0; it < 7; it++) {
var mid = (lo + hi) / 2;
ctx.font = '700 ' + mid + 'px sans-serif';
if (ctx.measureText(text).width <= arcLen) { best = mid; lo = mid; } else { hi = mid; }
}
return best;
}
글자 크기 후보 9단계를 7번의 measureText 로 좁히는 계산이고, 폭이 호 길이 안에 들면 답을 저장하고 하한을 올리고, 넘으면 상한을 내려 15px 한도 안에서 가장 큰 들어가는 크기를 돌려줍니다.
08연속 뽑기 — 당첨 칸을 빼고 도는 탈락 모드
뽑힌 항목을 alive 배열에서 splice 로 빼고 log 에 쌓아 남은 항목으로 각도를 재분배하면 발표자 뽑기·빙고 뽑기가 됩니다. 되돌리기는 log.pop 한 값을 alive 로 다시 push 해 직전 뽑기를 취소합니다.

<main class="stage">
<div class="el" id="el">
<p class="el__hint">뽑힌 칸은 빠지고 남은 칸으로 각도를 재분배합니다 — 되돌리기도 있습니다</p>
<div class="el__row">
<div class="el__field" id="field" role="application" aria-label="탈락 모드 실험판" tabindex="0">
<canvas class="el__cv" id="cv"></canvas>
</div>
<div class="el__side">
<b class="el__num" id="left">6</b>
<span class="el__unit">남은 항목 · 뽑히면 줄어든다</span>
<div class="el__log" id="log"></div>
<button class="el__btn" id="btn" type="button">직전 뽑기 되돌리기</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 연속 뽑기 탈락 모드(08) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 페이퍼 무대. 칸 여섯 색은 파스텔 — 뽑힌 칸이 빠질 때 유령 프레임이 안 생기게
// 반투명이 아니라 채도만 낮춘 미리보기 색을 캔버스에서 계산해 쓴다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.el {
--w1: #b3bfd4; // 파랑빛 파스텔 — 1번 칸
--w2: #d8a3b4; // 로즈 파스텔 — 2번 칸
--w3: #a9c4b0; // 세이지 파스텔 — 3번 칸
--w4: #d9c48f; // 골드 파스텔 — 4번 칸
--w5: #c2a8d1; // 플럼 파스텔 — 5번 칸
--w6: #9fc6c2; // 틸 파스텔 — 6번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.el__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.el__row { display: flex; gap: $sp-2; align-items: stretch; }
.el__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;
}
.el__field:active { cursor: grabbing; }
.el__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.el__cv { display: block; width: 100%; height: 100%; }
.el__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.el__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-blue-deep; font-variant-numeric: tabular-nums;
}
.el__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.el__log { display: flex; flex-direction: column; gap: $sp-1; margin-top: $sp-1; }
.el__chip {
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba($subject-ink, .08);
color: $subject-ink;
font-weight: 600; font-size: 10px; line-height: 1.4;
width: fit-content;
}
.el__btn {
border: 0; cursor: pointer;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: $subject-blue-deep; color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.2;
font-family: inherit;
width: fit-content;
}
.el__btn:disabled { opacity: .45; cursor: default; }
.el__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
@media (max-width: 340px) {
.el__hint { font-size: 8px; line-height: 1.3; }
.el { gap: $sp-1; }
.el__field { width: 150px; height: 150px; }
.el__num { font-size: 16px; }
.el__chip { padding: $sp-1; font-size: 9px; }
.el__btn { padding: $sp-1; font-size: 9px; }
}
// reduce — 루프가 안 돈다. 두 명이 빠진 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.el.is-still .el__field { cursor: default !important; }
.el.is-still .el__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.el {
--w1: #b3bfd4;
--w2: #d8a3b4;
--w3: #a9c4b0;
--w4: #d9c48f;
--w5: #c2a8d1;
--w6: #9fc6c2;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.el__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.el__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.el__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;
}
.el__field:active {
cursor: grabbing;
}
.el__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.el__cv {
display: block;
width: 100%;
height: 100%;
}
.el__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.el__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
}
.el__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.el__log {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.el__chip {
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
width: fit-content;
}
.el__btn {
border: 0;
cursor: pointer;
border-radius: 4px;
padding: 4px 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
font-family: inherit;
width: fit-content;
}
.el__btn:disabled {
opacity: 0.45;
cursor: default;
}
.el__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.el__hint {
font-size: 8px;
line-height: 1.3;
}
.el {
gap: 4px;
}
.el__field {
width: 150px;
height: 150px;
}
.el__num {
font-size: 16px;
}
.el__chip {
padding: 4px;
font-size: 9px;
}
.el__btn {
padding: 4px;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.el.is-still .el__field {
cursor: default !important;
}
.el.is-still .el__btn {
cursor: default;
}
}// 연속 뽑기 탈락 모드(08) — 뽑힌 항목을 목록에서 빼고 남은 항목으로 각도를 재분배하면
// 발표자 뽑기·빙고 뽑기가 된다. 당첨자는 사이드 기록 판에 옮겨 적히고 되돌리기로
// 직전 뽑기를 취소할 수 있다. 확정 전 미리보기 칸은 반투명이 아니라 채도만 낮춘다.
(function () {
var root = document.getElementById('el');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var leftEl = document.getElementById('left');
var logEl = document.getElementById('log');
var btnEl = document.getElementById('btn');
if (!root || !field || !cv || !leftEl || !logEl || !btnEl) 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 ALL = ['김하늘', '이준호', '박서연', '최도윤', '정민준', '한수아'];
var alive = [0, 1, 2, 3, 4, 5]; // 살아있는 항목 인덱스 — PAL·ALL 둘 다 이 숫자로 본다
var log = []; // 뽑힌 순서 — 되돌리기의 재료
var ang = -0.5, vel = 0;
var FRICTION = 0.28, STOP = 0.5;
var settle = -1, pulse = -1, dpr = 1;
var previewIdx = -1; // 정지 직후 확정 전 하이라이트
var flicks = [9, -11, 7.5, 12];
var fi = 0, idle = 0, empty = 0;
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 norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) {
var n = alive.length;
return n ? Math.floor(norm(-Math.PI / 2 - a) / (TAU / n)) % n : -1;
}
// hex 채도를 낮춘 미리보기 색 — 반투명이 아니라 채도 조절이라 유령이 안 생긴다
function desat(hex) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
var avg = (r + g + b) / 3;
r = Math.round(r * 0.45 + avg * 0.55);
g = Math.round(g * 0.45 + avg * 0.55);
b = Math.round(b * 0.45 + avg * 0.55);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
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 n = alive.length, seg = TAU / n;
var cx = w / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
if (!n) {
ctx.fillStyle = INK;
ctx.font = '700 13px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('전원 완료', cx, cy);
return;
}
ctx.save(); ctx.translate(cx, cy); ctx.rotate(ang);
for (var k = 0; k < n; k++) {
var a0 = k * seg;
var base = PAL[alive[k] % PAL.length];
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + seg); ctx.closePath();
ctx.fillStyle = (settle >= 0 && k === previewIdx) ? desat(base) : base;
ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.20)'; ctx.lineWidth = 1; ctx.stroke();
var mid = a0 + seg / 2;
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
ctx.font = '700 11px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(ALL[alive[k]], R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.35)'; ctx.stroke();
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 12 + pulse * 20, 0, TAU);
ctx.strokeStyle = 'rgba(23,20,26,' + (1 - pulse) * 0.5 + ')';
ctx.lineWidth = 2; ctx.stroke();
}
// 확정 마커 — 뽑혀서 빠진 인원수만큼 허브 아래 사각 칩(fillRect)으로 쌓는다
var done = ALL.length - alive.length;
for (var d2 = 0; d2 < done; d2++) {
ctx.fillStyle = PAL[d2 % PAL.length];
ctx.fillRect(-9 + d2 * 4.4, 18, 3.4, 3.4);
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 6, cy - R - 8); ctx.lineTo(cx + 6, cy - R - 8); ctx.lineTo(cx, cy - R + 7);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
function renderLog() {
logEl.textContent = '';
for (var q = log.length - 1; q >= 0; q--) {
var row = document.createElement('span');
row.className = 'el__chip';
row.textContent = (q + 1) + '번 ' + ALL[log[q]];
logEl.appendChild(row);
}
leftEl.textContent = String(alive.length);
btnEl.disabled = !log.length;
}
// 확정 — 뽑힌 항목을 빼고 각도를 재분배한다
function commit() {
if (previewIdx < 0 || !alive.length) return;
var winner = alive.splice(previewIdx, 1)[0];
log.push(winner);
previewIdx = -1; settle = -1; pulse = -1;
renderLog();
}
btnEl.addEventListener('click', function () {
if (!log.length) return;
var back = log.pop();
alive.push(back);
previewIdx = -1; settle = -1; pulse = -1;
renderLog();
idle = 0.8;
});
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;
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-20, Math.min(20, drag.v));
if (Math.abs(vel) < 1.5) vel = flicks[0];
settle = -1; pulse = -1;
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (alive.length) { vel = flicks[fi++ % flicks.length]; settle = -1; pulse = -1; }
}
});
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 (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; previewIdx = idxAt(ang); }
} else if (idle > 0) { idle -= dt; }
else if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 0.62) {
commit(); // 확정 — 칸이 빠지고 재분배
if (alive.length) { vel = flicks[fi++ % flicks.length]; settle = -1; pulse = -1; }
}
} else if (alive.length) {
vel = flicks[fi++ % flicks.length];
} else {
// 전원 완료 — 잠깐 보여 주고 처음부터 다시 시작한다
empty += dt;
if (empty > 0.7) {
alive = [0, 1, 2, 3, 4, 5]; log = []; previewIdx = -1; empty = 0;
renderLog();
vel = flicks[fi++ % flicks.length];
}
}
}
draw();
requestAnimationFrame(step);
}
fit();
renderLog();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 두 명이 빠진 상태로 고정하고 루프는 돌지 않는다
alive = [0, 1, 3, 5];
log = [4, 2];
ang = -0.5 + 2.2;
renderLog();
draw();
root.classList.add('is-still');
} else {
vel = flicks[fi++ % flicks.length];
requestAnimationFrame(step);
}
})();// SpinWheelEliminationMode — vanilla 판(run/332/demo/08-spin-wheel-elimination-mode/index.html) 의 React 포트.
// alive 인덱스 배열이 살아있는 항목이고, 뽑히면 splice 로 빼고 남은 칸 수로 각도를 재분배한다.
// 되돌리기는 log 배열에서 pop 해 alive 에 되돌린다. 확정 전 미리보기 칸은 반투명이 아니라
// desat() 로 채도만 낮춘 색을 쓴다 — 유령 프레임이 안 생긴다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpinWheelEliminationMode.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelEliminationModeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const ALL = ["김하늘", "이준호", "박서연", "최도윤", "정민준", "한수아"];
const FRICTION = 0.28, STOP = 0.5;
const FLICKS = [9, -11, 7.5, 12];
const PAL = ["#b3bfd4", "#d8a3b4", "#a9c4b0", "#d9c48f", "#c2a8d1", "#9fc6c2"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "뽑힌 칸은 빠지고 남은 칸으로 각도를 재분배합니다 — 되돌리기도 있습니다";
const ARIA_FIELD = "탈락 모드 실험판";
const UNIT = "남은 항목 · 뽑히면 줄어든다";
const UNDO_LABEL = "직전 뽑기 되돌리기";
const ALL_DONE = "전원 완료";
// hex 채도를 낮춘 미리보기 색 — 반투명이 아니라 채도 조절이라 유령이 안 생긴다
function desat(hex: string): string {
let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
const avg = (r + g + b) / 3;
r = Math.round(r * 0.45 + avg * 0.55);
g = Math.round(g * 0.45 + avg * 0.55);
b = Math.round(b * 0.45 + avg * 0.55);
return "rgb(" + r + "," + g + "," + b + ")";
}
export default function SpinWheelEliminationMode({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: SpinWheelEliminationModeProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const leftRef = useRef<HTMLSpanElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
ang: -0.5, vel: 0, settle: -1, pulse: -1, previewIdx: -1, dpr: 1, fi: 0, idle: 0, empty: 0,
alive: [0, 1, 2, 3, 4, 5], log: [] as number[],
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; settle: number; pulse: number; previewIdx: number; dpr: number; fi: number; idle: number; empty: number;
alive: number[]; log: number[];
drag: { on: boolean; lastA: number; lastT: number; v: number };
}>;
const [logChips, setLogChips] = useState<number[]>([]);
const [aliveCount, setAliveCount] = useState(6);
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 norm(a: number): number {
const r = a % TAU;
return r < 0 ? r + TAU : r;
}
function idxAt(a: number): number {
const n = stRef.current.alive.length;
return n ? Math.floor(norm(-Math.PI / 2 - a) / (TAU / n)) % n : -1;
}
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 n = st.alive.length, seg = TAU / n;
const cx = w / 2, cy = h / 2, R = Math.min(w, h) / 2 - 10;
if (!n) {
ctx.fillStyle = INK;
ctx.font = "700 13px sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(ALL_DONE, cx, cy);
return;
}
ctx.save(); ctx.translate(cx, cy); ctx.rotate(st.ang);
for (let k = 0; k < n; k++) {
const a0 = k * seg;
const base = PAL[st.alive[k] % PAL.length];
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + seg); ctx.closePath();
ctx.fillStyle = (st.settle >= 0 && k === st.previewIdx) ? desat(base) : base;
ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.20)"; ctx.lineWidth = 1; ctx.stroke();
const mid = a0 + seg / 2;
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
ctx.font = "700 11px sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText(ALL[st.alive[k]], R - 12, 0);
ctx.restore();
}
ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.35)"; ctx.stroke();
if (st.pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 12 + st.pulse * 20, 0, TAU);
ctx.strokeStyle = "rgba(23,20,26," + (1 - st.pulse) * 0.5 + ")";
ctx.lineWidth = 2; ctx.stroke();
}
// 확정 마커 — 뽑혀서 빠진 인원수만큼 허브 아래 사각 칩(fillRect)으로 쌓는다
const done = ALL.length - st.alive.length;
for (let d2 = 0; d2 < done; d2++) {
ctx.fillStyle = PAL[d2 % PAL.length];
ctx.fillRect(-9 + d2 * 4.4, 18, 3.4, 3.4);
}
ctx.restore();
ctx.beginPath();
ctx.moveTo(cx - 6, cy - R - 8); ctx.lineTo(cx + 6, cy - R - 8); ctx.lineTo(cx, cy - R + 7);
ctx.closePath(); ctx.fillStyle = INK; ctx.fill();
}
function syncSide(): void {
const st = stRef.current;
setLogChips(st.log.slice());
setAliveCount(st.alive.length);
const lf = leftRef.current;
if (lf) lf.textContent = String(st.alive.length);
}
// 확정 — 뽑힌 항목을 빼고 각도를 재분배한다
function commit(): void {
const st = stRef.current;
if (st.previewIdx < 0 || !st.alive.length) return;
const winner = st.alive.splice(st.previewIdx, 1)[0];
st.log.push(winner);
st.previewIdx = -1; st.settle = -1; st.pulse = -1;
syncSide();
}
function undo(): void {
const st = stRef.current;
if (!st.log.length) return;
const back = st.log.pop()!;
st.alive.push(back);
st.previewIdx = -1; st.settle = -1; st.pulse = -1;
syncSide();
st.idle = 0.8;
}
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;
st.idle = 0;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-20, Math.min(20, st.drag.v));
if (Math.abs(st.vel) < 1.5) st.vel = FLICKS[0];
st.settle = -1; st.pulse = -1;
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (st.alive.length) { st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; }
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; st.previewIdx = idxAt(st.ang); }
} else if (st.idle > 0) { st.idle -= dt; }
else if (st.settle >= 0) {
st.settle += dt; st.pulse = Math.min(1, st.settle / 0.3);
if (st.settle > 0.62) {
commit(); // 확정 — 칸이 빠지고 재분배
if (st.alive.length) { st.vel = FLICKS[st.fi++ % FLICKS.length]; st.settle = -1; st.pulse = -1; }
}
} else if (st.alive.length) {
st.vel = FLICKS[st.fi++ % FLICKS.length];
} else {
// 전원 완료 — 잠깐 보여 주고 처음부터 다시 시작한다
st.empty += dt;
if (st.empty > 0.7) {
st.alive = [0, 1, 2, 3, 4, 5]; st.log = []; st.previewIdx = -1; st.empty = 0;
syncSide();
st.vel = FLICKS[st.fi++ % FLICKS.length];
}
}
}
draw();
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 두 명이 빠진 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
st.alive = [0, 1, 3, 5];
st.log = [4, 2];
st.ang = -0.5 + 2.2;
syncSide();
draw();
setStill(true);
} else {
stRef.current.vel = FLICKS[stRef.current.fi++ % FLICKS.length];
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.el + " " + styles.isStill : styles.el;
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} ref={leftRef}>{aliveCount}</b>
<span className={styles.unit}>{UNIT}</span>
<div className={styles.log}>
{/* vanilla 와 같이 최근 뽑기부터 거꾸로 나열한다 — 번호는 뽑힌 순서 그대로 */}
{logChips.map((_, i) => {
const q = logChips.length - 1 - i;
return <span key={q} className={styles.chip}>{q + 1}번 {ALL[logChips[q]]}</span>;
})}
</div>
<button type="button" className={styles.btn} disabled={!logChips.length} onClick={undo}>
{UNDO_LABEL}
</button>
</div>
</div>
</div>
</main>
);
}08 · 연속 뽑기 — 당첨 칸을 빼고 도는 탈락 모드 — 측정 누적면적 21.825% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
확정과 되돌리기 — splice·push·pop 세 동작
function commit() {
if (previewIdx < 0 || !alive.length) return;
var winner = alive.splice(previewIdx, 1)[0];
log.push(winner);
previewIdx = -1; settle = -1; pulse = -1;
renderLog();
}
btnEl.addEventListener('click', function () {
if (!log.length) return;
var back = log.pop();
alive.push(back);
빼낸 인덱스를 log 가 순서대로 기억하므로 되돌리기는 마지막 값 하나를 되살리는 pop 과 push 로 끝나며, 항목 수가 바뀌면 다음 회전의 각도 분할이 남은 수로 다시 계산됩니다.
09히어로 씬 — 폭죽 터지고 결과를 저장하는 완성판
정지 판정 직후 mulberry32 시드 90210 고정 난수로 파티클 42개를 방사형으로 쏘고(중력 220·감쇠 0.75/s), 당첨명이 올라온 뒤 판 전체를 toDataURL('image/png') 로 내보냅니다. 마찰·틱(스프링)·가중치·탈락 모드를 한 화면에 얹은 완성판입니다.

<main class="stage">
<div class="hr" id="hr">
<p class="hr__hint">정지 판정 직후 폭죽이 터지고 결과를 올립니다 — 저장 버튼은 판을 toDataURL 로 내보냅니다</p>
<div class="hr__row">
<div class="hr__field" id="field" role="application" aria-label="룰렛 히어로" tabindex="0">
<canvas class="hr__cv" id="cv"></canvas>
</div>
<div class="hr__side">
<b class="hr__win" id="win">READY</b>
<span class="hr__unit">정지하면 결과가 크게 오릅니다</span>
<a class="hr__save" id="save" href="#" download="roulette.png">판 저장하기</a>
<p class="hr__meta">마찰·틱·가중치·탈락을 한 화면에</p>
<p class="hr__sr" id="live" aria-live="polite"></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 히어로 씬(09) — 움직임은 전부 캔버스 rAF 루프다. CSS @keyframes 를 두지 않는다.
// 잉크 무대. 칸 여덟 색은 파스텔 팔레트 — 폭죽 파티클도 같은 팔레트를 써 판과 한몸이다.
// 결과 리본·후프는 크림 면 + 잉크 선 — 어두운 무대 위 대비 17:1.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
.hr {
--w1: #a6b4d3; // 파랑빛 파스텔 — 1번 칸
--w2: #d9c48f; // 골드 파스텔 — 2번 칸
--w3: #a6d3b1; // 연두 파스텔 — 3번 칸
--w4: #d8a3b4; // 로즈 파스텔 — 4번 칸
--w5: #9fc6c2; // 틸 파스텔 — 5번 칸
--w6: #c2a8d1; // 플럼 파스텔 — 6번 칸
--w7: #e0c6a2; // 모래 파스텔 — 7번 칸
--w8: #a9c4b0; // 세이지 파스텔 — 8번 칸
--ink: #{$subject-ink};
--cream: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-1;
width: 100%; max-width: 436px;
}
.hr__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.hr__row { display: flex; gap: $sp-2; align-items: stretch; }
.hr__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 228px);
height: clamp(176px, 46vw, 228px);
border-radius: $r-card;
background: rgba($subject-cream, .04);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: grab; touch-action: none;
}
.hr__field:active { cursor: grabbing; }
.hr__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.hr__cv { display: block; width: 100%; height: 100%; }
.hr__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.hr__win {
font-weight: 800; font-size: clamp(18px, 5vw, 26px); line-height: 1.1;
color: $stage-yellow;
}
.hr__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .82); }
.hr__save {
display: inline-flex; align-items: center; width: fit-content;
margin-top: $sp-1;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: $subject-cream; color: $subject-ink;
font-weight: 700; font-size: 11px; line-height: 1.2;
text-decoration: none;
}
.hr__save:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.hr__save:active { transform: translateY(1px); }
.hr__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .82); }
.hr__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); margin: -1px; }
@media (max-width: 340px) {
.hr__hint { font-size: 8px; line-height: 1.3; }
.hr { gap: $sp-1; }
.hr__field { width: 148px; height: 148px; }
.hr__win { font-size: 15px; }
.hr__meta { margin-top: 0; font-size: 8px; line-height: 1.2; }
.hr__unit { display: none; }
.hr__side { gap: 0; }
.hr__save { padding: $sp-1; font-size: 9px; }
}
// reduce — 루프가 안 돈다. 폭죽이 흩어진 정지 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.hr.is-still .hr__field { cursor: default !important; }
}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);
}
.hr {
--w1: #a6b4d3;
--w2: #d9c48f;
--w3: #a6d3b1;
--w4: #d8a3b4;
--w5: #9fc6c2;
--w6: #c2a8d1;
--w7: #e0c6a2;
--w8: #a9c4b0;
--ink: #17141a;
--cream: #fff7e6;
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 436px;
}
.hr__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.hr__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.hr__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 228px);
height: clamp(176px, 46vw, 228px);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: grab;
touch-action: none;
}
.hr__field:active {
cursor: grabbing;
}
.hr__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.hr__cv {
display: block;
width: 100%;
height: 100%;
}
.hr__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.hr__win {
font-weight: 800;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.1;
color: #ffd23f;
}
.hr__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.82);
}
.hr__save {
display: inline-flex;
align-items: center;
width: fit-content;
margin-top: 4px;
border-radius: 4px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
text-decoration: none;
}
.hr__save:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.hr__save:active {
transform: translateY(1px);
}
.hr__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
}
.hr__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
margin: -1px;
}
@media (max-width: 340px) {
.hr__hint {
font-size: 8px;
line-height: 1.3;
}
.hr {
gap: 4px;
}
.hr__field {
width: 148px;
height: 148px;
}
.hr__win {
font-size: 15px;
}
.hr__meta {
margin-top: 0;
font-size: 8px;
line-height: 1.2;
}
.hr__unit {
display: none;
}
.hr__side {
gap: 0;
}
.hr__save {
padding: 4px;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.hr.is-still .hr__field {
cursor: default !important;
}
}// 히어로 씬(09) — 정지 판정 직후 파티클 폭죽을 터뜨리고 당첨명을 크게 올린 뒤 판 전체를
// toDataURL 로 내보낸다. 마찰·틱(스프링)·가중치·탈락 모드를 한 화면에 얹은 히어로다.
// 파티클은 시드 고정 mulberry32 로 뽑는다 — 데모 재현성.
(function () {
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var root = document.getElementById('hr');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var winEl = document.getElementById('win');
var saveEl = document.getElementById('save');
var liveEl = document.getElementById('live');
if (!root || !field || !cv || !winEl || !saveEl || !liveEl) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PAL = [];
for (var i = 1; i <= 8; 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 PRIZES = [
{ name: '꽝', w: 3 },
{ name: '쿠키', w: 3 },
{ name: '아메리카노', w: 2 },
{ name: '커피 기프티콘', w: 1 },
{ name: '삼각김밥', w: 2 },
{ name: '벌칙', w: 2 },
{ name: '초콜릿', w: 3 },
{ name: '1000원', w: 2 }
];
var TOT = PRIZES.reduce(function (s, p) { return s + p.w; }, 0);
var bounds = [], acc = 0;
for (var q = 0; q < PRIZES.length; q++) { bounds.push(acc / TOT); acc += PRIZES[q].w; }
bounds.push(1);
var ang = 0, vel = 0;
var FRICTION = 0.20, STOP = 0.4; // 낮은 마찰 — 히어로는 길게 돈다
var pk = 340, pc = 14, pX = 0, pV = 0;
var lastQ = 0, ticks = 0;
var settle = -1, pulse = -1, dpr = 1;
var burst = []; // 폭죽 파티클
var winShow = 0; // 결과 표시 애니메이션 (0~1)
var winner = -1;
var rnd = mulberry32(90210);
var flicks = [16, -13, 18];
var fi = 1, idle = 0;
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 norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) {
var u = norm(-Math.PI / 2 - a);
for (var k = 0; k < PRIZES.length; k++) if (u >= bounds[k] && u < bounds[k + 1]) return k;
return PRIZES.length - 1;
}
function spawnBurst(cx, cy) {
for (var i = 0; i < 42; i++) {
var a = rnd() * TAU, sp = 60 + rnd() * 170;
burst.push({
x: cx, y: cy,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
rot: rnd() * TAU, vr: (rnd() - 0.5) * 10,
s: 3 + rnd() * 4,
col: PAL[Math.floor(rnd() * PAL.length)],
life: 1
});
}
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 12;
// 외곽 후프 — 히어로 판의 테두리
ctx.beginPath(); ctx.arc(cx, cy, R + 7, 0, TAU);
ctx.strokeStyle = 'rgba(255,247,230,.25)'; ctx.lineWidth = 5; ctx.stroke();
ctx.save(); ctx.translate(cx, cy); ctx.rotate(ang);
for (var k = 0; k < PRIZES.length; k++) {
var a0 = bounds[k] * TAU, a1 = bounds[k + 1] * TAU;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1); ctx.closePath();
ctx.fillStyle = PAL[k]; ctx.fill();
ctx.strokeStyle = 'rgba(23,20,26,.22)'; ctx.lineWidth = 1; ctx.stroke();
var mid = (a0 + a1) / 2;
var arcLen = R * (a1 - a0);
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
var fs = 9;
ctx.font = '700 ' + fs + 'px sans-serif';
while (ctx.measureText(PRIZES[k].name).width > arcLen * 0.82 && fs > 5) {
fs -= 1; ctx.font = '700 ' + fs + 'px sans-serif';
}
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(PRIZES[k].name, R - 11, 0);
ctx.restore();
}
for (var p = 0; p < PRIZES.length; p++) {
var pa = bounds[p] * TAU;
ctx.beginPath();
ctx.arc(Math.cos(pa) * (R - 3), Math.sin(pa) * (R - 3), 2.4, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
}
ctx.beginPath(); ctx.arc(0, 0, 14, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = 'rgba(23,20,26,.4)'; ctx.stroke();
if (pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 14 + pulse * 26, 0, TAU);
ctx.strokeStyle = 'rgba(255,247,230,' + (1 - pulse) * 0.8 + ')';
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
// 12시 포인터 — 스프링 변위
var tipY = cy - R + 7;
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 9); ctx.lineTo(cx + 7, cy - R - 9); ctx.lineTo(cx + pX, tipY);
ctx.closePath();
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
// 폭죽 파티클
for (var b = burst.length - 1; b >= 0; b--) {
var P = burst[b];
if (P.life <= 0) { burst.splice(b, 1); continue; }
ctx.save();
ctx.translate(P.x, P.y);
ctx.rotate(P.rot);
ctx.globalAlpha = Math.max(0, P.life);
ctx.fillStyle = P.col;
ctx.fillRect(-P.s / 2, -P.s / 4, P.s, P.s / 2);
ctx.restore();
}
ctx.globalAlpha = 1;
// 결과 리본 — 허브 아래에서 올라온다 (winShow 0→1)
if (winShow > 0 && winner >= 0) {
var lift = (1 - winShow) * 18;
ctx.save();
ctx.translate(cx, Math.min(h - 12, cy + R * 0.62) + lift);
ctx.globalAlpha = Math.min(1, winShow * 1.4);
var label = winner + 1 + '번 ' + PRIZES[winner].name;
ctx.font = '800 15px sans-serif';
var tw = ctx.measureText(label).width;
ctx.beginPath();
ctx.roundRect(-tw / 2 - 10, -12, tw + 20, 24, 8);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = INK;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, 0, 0);
ctx.restore();
}
ctx.globalAlpha = 1;
}
function tickCheck() {
var q2 = Math.floor(ang / (TAU / PRIZES.length));
if (q2 !== lastQ) {
var d = q2 - lastQ;
ticks += Math.abs(d);
pV += (d > 0 ? 1 : -1) * Math.abs(vel) * 22;
lastQ = q2;
}
}
function judge() {
winner = idxAt(ang);
winEl.textContent = PRIZES[winner].name;
liveEl.textContent = '당첨 ' + PRIZES[winner].name;
var w = cv.width / dpr, h = cv.height / dpr;
spawnBurst(w / 2, h / 2);
winShow = 0.001;
}
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;
tickCheck();
idle = 0;
});
function endDrag() {
if (!drag.on) return;
drag.on = false;
vel = Math.max(-26, Math.min(26, drag.v));
if (Math.abs(vel) < 1.5) vel = flicks[0];
settle = -1; pulse = -1; winShow = 0;
}
field.addEventListener('pointerup', endDrag);
field.addEventListener('pointercancel', endDrag);
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
vel = flicks[fi++ % flicks.length];
settle = -1; pulse = -1; winShow = 0; idle = 0;
}
});
saveEl.addEventListener('click', function (e) {
e.preventDefault();
try {
saveEl.href = cv.toDataURL('image/png');
} catch (err) { /* 데이터 URL 실패 시 기본 링크 유지 */ }
});
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;
// 포인터 스프링 적분
var acc = -pk * pX - pc * pV;
pV += acc * dt; pX += pV * dt;
if (Math.abs(pX) < 0.02 && Math.abs(pV) < 1) { pX = 0; pV = 0; }
// 파티클 물리 — 중력 + 감쇠
for (var i = 0; i < burst.length; i++) {
var P = burst[i];
P.vy += 220 * dt;
P.x += P.vx * dt; P.y += P.vy * dt;
P.rot += P.vr * dt;
P.life -= dt * 0.75;
}
if (winShow > 0 && winShow < 1) winShow = Math.min(1, winShow + dt * 3.2);
if (!drag.on) {
if (vel !== 0) {
ang += vel * dt;
vel *= Math.pow(FRICTION, dt);
tickCheck();
if (Math.abs(vel) < STOP) { vel = 0; settle = 0; pulse = 0; judge(); }
} else if (idle > 0) { idle -= dt; }
else if (settle >= 0) {
settle += dt; pulse = Math.min(1, settle / 0.3);
if (settle > 1.3) {
vel = flicks[fi++ % flicks.length];
settle = -1; pulse = -1; winShow = 0;
}
}
}
draw();
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 판정·폭죽이 끝난 상태로 고정하고 루프는 돌지 않는다
ang = -Math.PI / 2 - 1.32;
winner = idxAt(ang);
winEl.textContent = PRIZES[winner].name;
liveEl.textContent = '당첨 ' + PRIZES[winner].name;
winShow = 1;
// 파티클은 정지 위치에 흩어진 한 장으로 미리 계산
for (var r2 = 0; r2 < 26; r2++) {
var a2 = rnd() * TAU, dd = 20 + rnd() * 34;
burst.push({ x: cv.width / dpr / 2 + Math.cos(a2) * dd, y: cv.height / dpr / 2 + Math.sin(a2) * dd - 10,
vx: 0, vy: 0, rot: rnd() * TAU, vr: 0, s: 3 + rnd() * 4, col: PAL[Math.floor(rnd() * PAL.length)], life: 1 });
}
pulse = 0.35;
draw();
root.classList.add('is-still');
} else {
vel = flicks[0];
requestAnimationFrame(step);
}
})();// SpinWheelHeroScene — vanilla 판(run/332/demo/09-spin-wheel-hero-scene/index.html) 의 React 포트.
// 마찰·틱(스프링)·가중치·탈락을 한 화면에 얹은 히어로다. 정지 판정 직후 파티클 폭죽(42알, 중력
// + 감쇠)이 터지고 당첨명 리본이 허브 아래에서 올라온다(winShow 0→1). 파티클은 시드 고정
// mulberry32(90210) 로 뽑는다 — 데모 재현성. 저장 버튼은 판을 toDataURL 로 내보낸다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent, MouseEvent } from "react";
import styles from "./SpinWheelHeroScene.module.scss";
// demo.py 의 ambient react 스텁은 KeyboardEvent 를 export 하지 않는다 — 최소 자체 타입으로 대체
type KeyEvt = { key: string; preventDefault(): void };
export type SpinWheelHeroSceneProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Particle = {
x: number; y: number; vx: number; vy: number;
rot: number; vr: number; s: number; col: string; life: number;
};
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const TAU = Math.PI * 2;
// 가중치 — 큰 상품은 칸이 넓다
const PRIZES = [
{ name: "꽝", w: 3 },
{ name: "쿠키", w: 3 },
{ name: "아메리카노", w: 2 },
{ name: "커피 기프티콘", w: 1 },
{ name: "삼각김밥", w: 2 },
{ name: "벌칙", w: 2 },
{ name: "초콜릿", w: 3 },
{ name: "1000원", w: 2 }
];
const TOT = PRIZES.reduce((s, p) => s + p.w, 0);
function computeBounds(): number[] {
const bounds: number[] = [];
let acc = 0;
for (let q = 0; q < PRIZES.length; q++) { bounds.push(acc / TOT); acc += PRIZES[q].w; }
bounds.push(1);
return bounds;
}
const BOUNDS = computeBounds();
const FRICTION = 0.20, STOP = 0.4; // 낮은 마찰 — 히어로는 길게 돈다
const PK = 340, PC = 14;
const FLICKS = [16, -13, 18];
const PAL = ["#a6b4d3", "#d9c48f", "#a6d3b1", "#d8a3b4", "#9fc6c2", "#c2a8d1", "#e0c6a2", "#a9c4b0"];
const INK = "#17141a";
const CREAM = "#fff7e6";
const HINT = "정지 판정 직후 폭죽이 터지고 결과를 올립니다 — 저장 버튼은 판을 toDataURL 로 내보냅니다";
const ARIA_FIELD = "룰렛 히어로";
const UNIT = "정지하면 결과가 크게 오릅니다";
const SAVE_LABEL = "판 저장하기";
const META_ALL = "마찰·틱·가중치·탈락을 한 화면에";
const READY = "READY";
export default function SpinWheelHeroScene({
duration = "2s",
easing = "ease-spring",
color = "#6ec8ff"
}: SpinWheelHeroSceneProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const winRef = useRef<HTMLSpanElement | null>(null);
const saveRef = useRef<HTMLAnchorElement | null>(null);
const liveRef = useRef<HTMLDivElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const rndRef = useRef(mulberry32(90210)) as Box<() => number>;
const stRef = useRef({
ang: 0, vel: 0, pX: 0, pV: 0, lastQ: 0,
settle: -1, pulse: -1, dpr: 1,
burst: [] as Particle[], winShow: 0, winner: -1,
fi: 1, idle: 0,
drag: { on: false, lastA: 0, lastT: 0, v: 0 }
}) as Box<{
ang: number; vel: number; pX: number; pV: number; lastQ: number;
settle: number; pulse: number; dpr: number;
burst: Particle[]; winShow: number; winner: number;
fi: number; idle: 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 norm(a: number): number {
const r = a % TAU;
return r < 0 ? r + TAU : r;
}
function idxAt(a: number): number {
const u = norm(-Math.PI / 2 - a);
for (let k = 0; k < PRIZES.length; k++) if (u >= BOUNDS[k] && u < BOUNDS[k + 1]) return k;
return PRIZES.length - 1;
}
function spawnBurst(cx: number, cy: number): void {
const st = stRef.current;
const rnd = rndRef.current;
for (let i = 0; i < 42; i++) {
const a = rnd() * TAU, sp = 60 + rnd() * 170;
st.burst.push({
x: cx, y: cy,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
rot: rnd() * TAU, vr: (rnd() - 0.5) * 10,
s: 3 + rnd() * 4,
col: PAL[Math.floor(rnd() * PAL.length)],
life: 1
});
}
}
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 / 2, cy = h / 2, R = Math.min(w, h) / 2 - 12;
// 외곽 후프 — 히어로 판의 테두리
ctx.beginPath(); ctx.arc(cx, cy, R + 7, 0, TAU);
ctx.strokeStyle = "rgba(255,247,230,.25)"; ctx.lineWidth = 5; ctx.stroke();
ctx.save(); ctx.translate(cx, cy); ctx.rotate(st.ang);
for (let k = 0; k < PRIZES.length; k++) {
const a0 = BOUNDS[k] * TAU, a1 = BOUNDS[k + 1] * TAU;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a1); ctx.closePath();
ctx.fillStyle = PAL[k]; ctx.fill();
ctx.strokeStyle = "rgba(23,20,26,.22)"; ctx.lineWidth = 1; ctx.stroke();
const mid = (a0 + a1) / 2;
const arcLen = R * (a1 - a0);
ctx.save(); ctx.rotate(mid);
ctx.fillStyle = INK;
let fs = 9;
ctx.font = "700 " + fs + "px sans-serif";
while (ctx.measureText(PRIZES[k].name).width > arcLen * 0.82 && fs > 5) {
fs -= 1; ctx.font = "700 " + fs + "px sans-serif";
}
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText(PRIZES[k].name, R - 11, 0);
ctx.restore();
}
for (let p = 0; p < PRIZES.length; p++) {
const pa = BOUNDS[p] * TAU;
ctx.beginPath();
ctx.arc(Math.cos(pa) * (R - 3), Math.sin(pa) * (R - 3), 2.4, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill();
}
ctx.beginPath(); ctx.arc(0, 0, 14, 0, TAU);
ctx.fillStyle = CREAM; ctx.fill(); ctx.strokeStyle = "rgba(23,20,26,.4)"; ctx.stroke();
if (st.pulse >= 0) {
ctx.beginPath(); ctx.arc(0, 0, 14 + st.pulse * 26, 0, TAU);
ctx.strokeStyle = "rgba(255,247,230," + (1 - st.pulse) * 0.8 + ")";
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
// 12시 포인터 — 스프링 변위
const tipY = cy - R + 7;
ctx.beginPath();
ctx.moveTo(cx - 7, cy - R - 9); ctx.lineTo(cx + 7, cy - R - 9); ctx.lineTo(cx + st.pX, tipY);
ctx.closePath();
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
// 폭죽 파티클
for (let b = st.burst.length - 1; b >= 0; b--) {
const P = st.burst[b];
if (P.life <= 0) { st.burst.splice(b, 1); continue; }
ctx.save();
ctx.translate(P.x, P.y);
ctx.rotate(P.rot);
ctx.globalAlpha = Math.max(0, P.life);
ctx.fillStyle = P.col;
ctx.fillRect(-P.s / 2, -P.s / 4, P.s, P.s / 2);
ctx.restore();
}
ctx.globalAlpha = 1;
// 결과 리본 — 허브 아래에서 올라온다 (winShow 0→1)
if (st.winShow > 0 && st.winner >= 0) {
const lift = (1 - st.winShow) * 18;
ctx.save();
ctx.translate(cx, Math.min(h - 12, cy + R * 0.62) + lift);
ctx.globalAlpha = Math.min(1, st.winShow * 1.4);
const label = st.winner + 1 + "번 " + PRIZES[st.winner].name;
ctx.font = "800 15px sans-serif";
const tw = ctx.measureText(label).width;
ctx.beginPath();
ctx.roundRect(-tw / 2 - 10, -12, tw + 20, 24, 8);
ctx.fillStyle = CREAM; ctx.fill();
ctx.strokeStyle = INK; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = INK;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(label, 0, 0);
ctx.restore();
}
ctx.globalAlpha = 1;
}
function tickCheck(): void {
const st = stRef.current;
const q2 = Math.floor(st.ang / (TAU / PRIZES.length));
if (q2 !== st.lastQ) {
const d = q2 - st.lastQ;
st.pV += (d > 0 ? 1 : -1) * Math.abs(st.vel) * 22;
st.lastQ = q2;
}
}
function judge(): void {
const st = stRef.current;
st.winner = idxAt(st.ang);
const win = winRef.current, live = liveRef.current;
if (win) win.textContent = PRIZES[st.winner].name;
if (live) live.textContent = "당첨 " + PRIZES[st.winner].name;
const w = cvRef.current!.width / st.dpr, h = cvRef.current!.height / st.dpr;
spawnBurst(w / 2, h / 2);
st.winShow = 0.001;
}
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;
tickCheck();
st.idle = 0;
}
function endDrag(): void {
const st = stRef.current;
if (!st.drag.on) return;
st.drag.on = false;
st.vel = Math.max(-26, Math.min(26, st.drag.v));
if (Math.abs(st.vel) < 1.5) st.vel = FLICKS[0];
st.settle = -1; st.pulse = -1; st.winShow = 0;
}
function onKeyDown(e: KeyEvt): void {
const st = stRef.current;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
st.vel = FLICKS[st.fi++ % FLICKS.length];
st.settle = -1; st.pulse = -1; st.winShow = 0; st.idle = 0;
}
}
function onSaveClick(e: MouseEvent<HTMLAnchorElement>): void {
e.preventDefault();
const cv = cvRef.current, save = saveRef.current;
if (!cv || !save) return;
try { save.href = cv.toDataURL("image/png"); } catch { /* 데이터 URL 실패 시 기본 링크 유지 */ }
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
// 포인터 스프링 적분
const acc = -PK * st.pX - PC * st.pV;
st.pV += acc * dt; st.pX += st.pV * dt;
if (Math.abs(st.pX) < 0.02 && Math.abs(st.pV) < 1) { st.pX = 0; st.pV = 0; }
// 파티클 물리 — 중력 + 감쇠
for (let i = 0; i < st.burst.length; i++) {
const P = st.burst[i];
P.vy += 220 * dt;
P.x += P.vx * dt; P.y += P.vy * dt;
P.rot += P.vr * dt;
P.life -= dt * 0.75;
}
if (st.winShow > 0 && st.winShow < 1) st.winShow = Math.min(1, st.winShow + dt * 3.2);
if (!st.drag.on) {
if (st.vel !== 0) {
st.ang += st.vel * dt;
st.vel *= Math.pow(FRICTION, dt);
tickCheck();
if (Math.abs(st.vel) < STOP) { st.vel = 0; st.settle = 0; st.pulse = 0; judge(); }
} else if (st.idle > 0) { st.idle -= dt; }
else if (st.settle >= 0) {
st.settle += dt; st.pulse = Math.min(1, st.settle / 0.3);
if (st.settle > 1.3) {
st.vel = FLICKS[st.fi++ % FLICKS.length];
st.settle = -1; st.pulse = -1; st.winShow = 0;
}
}
}
draw();
rafRef.current = requestAnimationFrame(step);
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 판정·폭죽이 끝난 상태로 고정하고 루프는 돌지 않는다
const st = stRef.current;
const rnd = rndRef.current;
st.ang = -Math.PI / 2 - 1.32;
st.winner = idxAt(st.ang);
const win = winRef.current, live = liveRef.current;
if (win) win.textContent = PRIZES[st.winner].name;
if (live) live.textContent = "당첨 " + PRIZES[st.winner].name;
st.winShow = 1;
// 파티클은 정지 위치에 흩어진 한 장으로 미리 계산
for (let r2 = 0; r2 < 26; r2++) {
const a2 = rnd() * TAU, dd = 20 + rnd() * 34;
st.burst.push({
x: cvRef.current!.width / st.dpr / 2 + Math.cos(a2) * dd,
y: cvRef.current!.height / st.dpr / 2 + Math.sin(a2) * dd - 10,
vx: 0, vy: 0, rot: rnd() * TAU, vr: 0, s: 3 + rnd() * 4,
col: PAL[Math.floor(rnd() * PAL.length)], life: 1
});
}
st.pulse = 0.35;
draw();
setStill(true);
} else {
stRef.current.vel = FLICKS[0];
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.hr + " " + styles.isStill : styles.hr;
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.win} ref={winRef}>{READY}</b>
<span className={styles.unit}>{UNIT}</span>
<a className={styles.save} ref={saveRef} href="#" download="roulette.png" onClick={onSaveClick}>
{SAVE_LABEL}
</a>
<p className={styles.meta}>{META_ALL}</p>
<div className={styles.sr} ref={liveRef} aria-live="polite"></div>
</div>
</div>
</div>
</main>
);
}09 · 히어로 씬 — 폭죽 터지고 결과를 저장하는 완성판 — 측정 누적면적 22.094% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
spawnBurst — 42개를 방사형으로 쏘고 중력으로 떨어뜨린다
function spawnBurst(cx, cy) {
for (var i = 0; i < 42; i++) {
var a = rnd() * TAU, sp = 60 + rnd() * 170;
burst.push({
x: cx, y: cy,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
rot: rnd() * TAU, vr: (rnd() - 0.5) * 10,
s: 3 + rnd() * 4,
col: PAL[Math.floor(rnd() * PAL.length)],
life: 1
});
}
}
var dt = Math.min(64, now - last) / 1000; last = now;
// 포인터 스프링 적분
var acc = -pk * pX - pc * pV;
pV += acc * dt; pX += pV * dt;
// 파티클 물리 — 중력 + 감쇠
for (var i = 0; i < burst.length; i++) {
var P = burst[i];
P.vy += 220 * dt;
P.x += P.vx * dt; P.y += P.vy * dt;
P.rot += P.vr * dt;
P.life -= dt * 0.75;
}
각 파티클이 방향·속력·회전을 시드 고정 난수로 받아 중력 220 을 적분하며 떨어지는 것이 폭죽의 전부이고, rnd 가 mulberry32(90210) 이므로 녹화와 실제 사용이 같은 불꽃을 재현합니다.
감쇠 계산 없이 이징으로 흉내 내면 깨지는 함정 두 가지
dt 를 clamp 없이 쓰면 판이 몇 바퀴를 건너뛴다
탭을 배경으로 보내면 requestAnimationFrame 이 프레임을 쌓아두므로 다음 dt 가 수 초가 될 수 있고, clamp 없이 ang += vel * dt 를 쓰면 판이 한 번에 몇 바퀴를 돌아 정지 각도가 완전히 달라집니다. 아홉 판 전부 첫 줄에서 Math.min(64, now - last) / 1000 로 상한을 두며, 사람 입력과 자동 시연이 다른 함수를 도는 이중 경로를 만들면 데모와 실제 튕김이 다른 결과를 내므로 둘은 같은 적분 루프를 지나야 합니다. 이 아홉 판의 원본 index.html 그대로를 묶은 암호 zip 의 비밀번호는 sgvkvgus 이고, 풀면 본문의 조각들과 줄 단위로 나란히 대조해 보실 수 있습니다.
정지를 이징으로 흉내 내면 세기가 죽는다
CSS transition 의 cubic-bezier 감속을 돌려 판을 멈추면 튕김 세기가 결과에 영향을 주지 못합니다 — 같은 곡선으로만 멈추니 위에서 아래로 튕기나 살살 굴리나 같은 회전이 나옵니다. 각속도 *= Math.pow(마찰계수, dt) 를 매 프레임 실제로 곱해야 세기가 회전 시간과 정지 각도에 그대로 이어지고, 그래야 손맛이 삽니다.
마찰 계수 하나로 손맛이 달라지는 변형과 접근성 처리
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 마찰 계수 | 03 마찰 0.38 → 0.08 | 마찰이 낮으면 판이 오래 도는 대신 기다림이 길어지고, 높으면 등골이 시원할 만큼 확 멈추지만 튕김의 맛이 줄어든다 |
| 칸 수 | 01 칸 8 → 18 | 칸이 많아지면 틱 소리가 촘촘해져 속도감이 살지만 글자가 작아져 한눈에 읽히는 이름의 크기가 희생된다 |
| 틱 흔들림 | 05 포인터 진폭 4px → 7px | 진폭을 키우면 실물 핀에 걸리는 촉감이 커지지만 지나치면 판정 각도와 포인터가 어긋나 보여 계산이 틀린 듯 느껴진다 |
동작을 줄인 사용자에게 남기는 장면
아홉 판 모두 시작 시 prefers-reduced-motion: reduce 를 검사해 켜져 있으면 루프를 돌리지 않고 결과가 이미 나온 정지 장면 한 장을 고정합니다 — 03 은 계수 0.18 로 시뮬레이션한 감속 곡선 전체를 미리 계산해 그린 그래프를, 09 는 판정과 폭죽이 끝난 상태에 흩어진 파티클 한 장을 보이므로 빈 캔버스가 아니라 결과가 남습니다.
스크린리더와 키보드로 만지는 아홉 판
판마다 role="application" 과 aria-label 이 붙고 포인터 입력은 setPointerCapture 로 캔버스 밖으로 나가도 놓치지 않으며(try/catch 로 감싼다), 당첨 결과는 aria-live="polite" 로 읽어줍니다 — 06 은 정지 각도를 도 단위로 함께 알려 계산이 눈에 보이는 판입니다.
어디에 붙이나 — 발표 순서 뽑기와 경품 추첨 화면
교실이나 사내 발표에서 순서를 정할 때는 08 연속 뽑기가 맞습니다 — 한 번 뽑힌 이름이 배열에서 빠지고 되돌리기도 가능해서, 발표자가 바뀌어도 같은 사람이 두 번 걸리지 않습니다. 사은품이나 경품을 나눌 때는 09 히어로 씬처럼 당첨 순간 폭죽이 터지고 결과 이미지를 저장할 수 있는 판이 화면 캡처 없이도 인증샷이 됩니다.
이름이나 상품명을 행사 직전에 바꿔 붙이는 자리라면 07 항목 편집기가 핵심입니다 — 줄 수가 늘거나 글자가 길어져도 칸 각도와 글자 크기가 함께 다시 계산되므로, 참석자 명단만 새로 넣으면 판이 저절로 맞춰집니다.
돌림판을 실제로 붙이기 전에 자주 나오는 질문
돌림판 만들기를 처음 시작해도 이 아홉 판으로 따라갈 수 있나요?
핵심은 01 의 부채꼴 그리기와 02 의 각속도 계산, 03 의 마찰 적분 세 동작입니다. zip 안에는 아홉 판을 같은 전제로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작하고, 그다음 마찰 계수와 칸 수를 하나씩 바꿔 보는 순서가 가장 빠릅니다.
랜덤 뽑기 돌림판의 당첨은 조작 없이 정말 공정한가요?
04 가 가중치를 두는 판조차 각도 자체가 확률입니다 — 시작각·끝각을 무게의 누적 비율로 나눠 그리므로 화면에 보이는 부채꼴 넓이가 곧 당첨 확률이고, 결과는 멈춘 뒤 그 각도로 판정합니다. 시드 고정 난수는 자동 시연의 재현을 위한 것이지 확률을 바꾸지 않습니다.
소리나 진동 설정 없이 발표 현장에서 쓸 수 있나요?
05 의 틱 소리는 사람이 직접 돌릴 때만 나므로 자동 반복·녹화 환경에서는 무음이고, 06 은 소리 없이 판정 결과를 화면과 스크린리더로 알립니다. 수업 발표 순서 뽑기에는 08 연속 뽑기가 당첨자를 빼고 도는 구조라 한 사람이 두 번 뽑히지 않게 해줍니다.









