web ルーレット — 弾いて回し摩擦で止まる回転盤キャンバス9種
web ルーレットを指ではじいて遊びたいなら、まず計算の中身を見せます。指ではじいた強さだけ回ります。角速度を積分し摩擦で減衰し、ポインターの下のマスを計算で判定します。重み付き確率・連続抽選・当たりの紙吹雪まで — キャンバス1枚、コードはパスワード付き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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 扇形ホイール — 極座標の弧でマスをつなぐ盤
- 02 はじき角速度 — ポインター移動を回転速度に変える
- 03 摩擦減衰 — 毎フレーム積分して自ら止まる盤
- 04 重み付き確率 — マスの角度を重みで分けて公正に抽選する
- 05 ポインターのカチッ — マス境界を通るたびに弾く音と揺れ
- 06 当選判定 — 停止角度を扇形インデックスに換算する
- 07 項目エディタ — 名前欄とマス角度が一緒に再計算される盤
- 08 連続抽選 — 当選マスを除いて回る脱落モード
- 09 ヒーローシーン — 紙吹雪が弾け結果を保存できる完成版
デモ
9項目とも同じ骨格(ポインター角度→角速度→摩擦積分→当選判定)を使っていますが、盤の見た目・重み付け・鳴る音は項目ごとに違います。実測値は次の表のとおりです。
| 番号 | 項目 | 累積面積 | 強度平均 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 扇形ホイール | 23.344% | 55.9 | 23/24 |
| 02 | はじき角速度 | 23.217% | 52.4 | 23/24 |
| 03 | 摩擦減衰 | 9.394% | 45.5 | 23/24 |
| 04 | 重み付き確率 | 22.052% | 56.0 | 23/24 |
| 05 | ポインターのカチッ | 22.926% | 55.8 | 23/24 |
| 06 | 当選判定 | 22.293% | 53.7 | 23/24 |
| 07 | 項目エディタ | 21.903% | 62.0 | 23/24 |
| 08 | 連続抽選 | 21.825% | 50.6 | 21/24 |
| 09 | ヒーローシーン | 22.094% | 60.1 | 23/24 |
03の累積面積だけ9.394%と低く見えますが、これは画面の半分を減速曲線のグラフパネルが占める構造上の値で、回転盤自体が小さいわけではありません。08も21.825%とやや低めですが、脱落記録を並べるサイドパネルが画面幅を分け合っているためで、残り7項目は22〜23%台にそろっています。05は境界を通るたびに音が鳴る項目ですが、drag.onが人の実際の操作中しか立たないため、自動演示とgif・mp4のキャプチャ中は意図的に無音のままです。
9ツールの段階別コード
社内の忘年会や授業の指名に使う抽選 ルーレットなら、04の重み付き確率と08の連続抽選を組み合わせるのが定番の組み方です。9項目とも下のコードはzip内のバニラJavaScript版とReact 18版の両方に対応する形で入っています。
01扇形ホイール — 極座標の弧でマスをつなぐ盤
moveTo(中心) → arc(開始角, 終了角) → closePath でマスを1つ描き、マス数だけ繰り返せば盤の土台が完成です。角度は2π/nで均等分割し、色はパレット配列を循環させて隣り合うマスが同色にならないようにします。累積面積23.344%・強度平均55.9・動いたフレーム23/24です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
扇形の境界線とマス番号を同時に描く
マスを塗った直後に境界線を1pxのストロークで重ね、扇の中央角度だけ回転させてから番号を右詰めで描くと、盤が回ってもラベルが常に外向きのまま読めます。
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();
}
02はじき角速度 — ポインター移動を回転速度に変える
pointermoveごとに中心からの角度をMath.atan2で測り、前回角度との差を経過時間で割ると rad/s の角速度が出ます。指を離した瞬間の値が初期回転速度になり、はじいた強さの分だけ盤が回ります。累積面積23.217%・強度平均52.4・動いたフレーム23/24です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
指を離した瞬間の値だけを初期速度にする
pointerdownで角度と時刻を記録し、pointermoveのたびに差分から速度を更新し続け、pointerupの瞬間のdrag.vだけを盤の初期角速度として採用します。
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;
});
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;
});
03摩擦減衰 — 毎フレーム積分して自ら止まる盤
rAFループでdtを測り、角度 += 角速度·dt、角速度 *= 摩擦^dt を積分すると、はじいた直後は速く、指数曲線で遅くなります。右側のグラフパネルが画面の半分を占めてその減衰曲線をリアルタイムに描くため累積面積は9.394%と他より小さく出ますが、これは構造上自然な値です。摩擦係数3段階のボタンで同じはじき(16 rad/s)の停止までの時間を比較できます。強度平均45.5・動いたフレーム23/24です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
摩擦係数3段階で同じはじきの減速を比べる
低・中・高の3つのボタンはCOEFS配列の値を切り替えるだけで、はじく速さそのものは固定のまま停止までの秒数だけを変えて見せます。
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();
}
04重み付き確率 — マスの角度を重みで分けて公正に抽選する
すべてのマスを同じ角度にするとマス数分の均等確率になりますが、項目ごとに重みを置くと開始角・終了角を累積比率で決め、角度そのものが確率になります。結果は停止後の角度で判定するので、操作なしで見える確率のまま当選します。累積面積22.052%・強度平均56.0・動いたフレーム23/24です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
累積比率が開始角と終了角を決める
重み配列Wの合計に対する各項目の比率を先に累積しておき、その累積比率を扇形の開始角・終了角の基準にすれば、マス中央に出す確率%の表示と実際の面積が常に一致します。
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;
}
05ポインターのカチッ — マス境界を通るたびに弾く音と揺れ
回転角度をマス境界角で割った商が変わるフレームを検出すると、ポインターがピンを通過した瞬間です。そのフレームでOscillatorNodeによる短いティックを鳴らし、ポインターを境界方向へわずかに押して減衰スプリングで戻すと、本物のピンが掛かる手触りが出ます。ただしティックが鳴るのは人が実際にドラッグしている間だけで、自動演示とgif・mp4のキャプチャ中はdrag.onがfalseのまま無音です。累積面積22.926%・強度平均55.8・動いたフレーム23/24です。

<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">音は今オフ — キャプチャ帧でティックが飛ぶためです</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
境界を通過したフレームだけティックを鳴らす
境界を跨いだ判定はしきい値付きの商の比較1つで済み、スプリングの復元力a = −pk·x − pc·vが同じフレームでポインターを押し戻すので、音と動きがずれません。
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;
}
}
06当選判定 — 停止角度を扇形インデックスに換算する
ポインターは12時方向に固定なので、停止角度を −π/2 基準で正規化してマス角で割ると当選インデックスが出ます。結果はaria-liveで読み上げ、判定・表示・再挑戦を1つの流れにつなげて、画面をつけたまま何度でも回せます。累積面積22.293%・強度平均53.7・動いたフレーム23/24です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
12時固定のポインターを基準に正規化する
norm(-Math.PI / 2 - ang)は停止角度を「12時から時計回りにどれだけ回ったか」という0〜2πの値に変換するだけの1行で、これをマス角で割った整数部が当選インデックスになります。
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];
}
07項目エディタ — 名前欄とマス角度が一緒に再計算される盤
名前を入力するとmeasureTextで幅を測り、マスの弧長に収まるまでフォントサイズを二分探索で縮めます。行の増減のたびに角度分割と配置をすべて再計算し、盤が編集内容にそのまま追従します。累積面積21.903%・強度平均62.0・動いたフレーム23/24です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
二分探索でフォントサイズをマスの弧長に収める
上限と下限のフォントサイズを7回だけ2分し、measureTextの幅がマスの弧長(R·seg·0.78)以下になる最大値を選ぶので、名前の長さがどれだけ違っても文字が枠からはみ出しません。
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;
}
08連続抽選 — 当選マスを除いて回る脱落モード
抽選された項目を外して残りで角度を再分配すると、発表者抽選やビンゴ抽選になります。当選者はサイドの記録パネルに移り、元に戻すで直前の抽選を取り消せます。サイドパネルが画面幅を分け合う構造のため累積面積は21.825%とやや低めで、動いたフレームも21/24とほかの項目より少なめです。強度平均50.6です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
配列のspliceとpushで確定と取り消しを両立する
生存中の項目は番号だけを持つ配列aliveで管理し、確定はspliceで1件抜いてlogへpush、取り消しはlogをpopしてaliveへ戻すだけなので、状態が常に配列の中身だけで説明できます。
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;
});
09ヒーローシーン — 紙吹雪が弾け結果を保存できる完成版
停止判定の直後にパーティクルの紙吹雪を弾けさせ、当選名を大きく表示し、盤全体をtoDataURLで書き出します。摩擦・ティック・重み・脱落モードを1画面に載せたヒーローバナー兼実用ツールです。累積面積22.094%・強度平均60.1・動いたフレーム23/24です。

<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">摩擦・ティック・重み・脱落を1画面に</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
停止判定の直後にパーティクルとtoDataURLをつなぐ
mulberry32のシード固定乱数でパーティクルの初速と角度だけを毎回同じ列にしておけば、重力と減衰で自然に落ちる紙吹雪でも自動演示のたびに同じ場面を再現できます。
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;
};
}
はじきがなぜ物理なのか — 角速度の積分と摩擦減衰
ライブラリを使わないjavascript ルーレットの実装では、この一連の積分ループそのものがすべての手触りを決めます。CSSのtransitionで減速イージングをかけるだけでは弾く強さが結果に反映されませんが、pointermoveのたびにMath.atan2で中心角を測り、前回角度との差を経過時間で割って角速度を作り、その角速度を毎フレーム摩擦係数のべき乗で減衰させると、はじく強さごとに回転時間も停止角度も実際に変わります。盤の見た目は違っても、画面ごとに毎フレーム格子や角度を計算し直す点は、以前扱った衝突判定で崩れないテトリスの9ステップと同じ考え方です。
角速度は指数移動平均で滑らかにする
瞬間の角度差をそのまま速度にすると指の震えでガクガクします。drag.v = 0.65 * drag.v + 0.35 * (dA / dt)のように直前の値を65%残す指数移動平均を挟むと、指を離した瞬間の値がなめらかな初期回転速度になります。
摩擦は毎フレームのべき乗で効く
角速度 *= Math.pow(摩擦係数, dt)は時間の経過分だけべき乗するので、フレームレートが変わっても同じ経過時間なら同じだけ減速します。角速度の絶対値がしきい値(STOP)を下回った瞬間にループを終え、その角度を停止角度として確定します。
当たりは角度計算 — ポインター判定と重み
12時方向に固定したポインターを基準に停止角度を正規化する点は9項目すべてで共通ですが、その先の割り算の対象が均等なマス角か累積比率(bounds)かで、実装は2つに分かれます。
均等確率のidxAtは角度をマス角で割った余り
均等確率のマスではnorm(-Math.PI / 2 - ang) / (TAU / n)をMath.floorした値がそのままインデックスになり、全マスの角度がすべて同じなので余りの分布も均等です。
重み付き確率のboundsは累積比率で開始角と終了角を決める
重み付きのマスでは累積比率の配列boundsを先に作っておき、正規化した停止角度がboundsのどの区間に入るかを線形に探すだけで、区間の幅=重みの比率=当選確率になります。
摩擦の積分を端折ってイージングで止めると壊れる落とし穴
作る途中でよく起きるつまずきと、その理由をまとめます。
| 失敗 | なぜ壊れるか |
|---|---|
| CSSのtransitionだけで減速を真似る | イージング曲線は弾く強さを反映できず、角速度を毎フレーム実際に積分してはじめて弾きごとに違う停止角度が出ます |
| dtをクランプせず使う | タブ切り替えなどでdtが一気に大きくなり、盤が1フレームで何周も飛んでしまいます |
| 重み付きマスを均等角度のまま確率だけ表示する | 画面上の角度そのものが確率でなければ、操作なしで公正な抽選になりません |
| 人の入力と自動演示を別関数で作る | 2つの経路が同じ摩擦積分ループを通らないと、デモと実際の弾きの手触りがずれます |
| 名前を編集しても角度分割を再計算しない | 古いマス配置が新しい項目数と合わなくなり、境界とラベルの位置がずれます |
CSSのイージングだけで停止を真似ない
減速イージングは見た目が似ていても弾く強さに反応しないため、はじく強さを変えても回転時間と停止角度がまったく変わらず、物理ではなく単なるアニメーションになってしまいます。
dtをクランプしないと数周飛ぶ
Math.min(64, now - last) / 1000のように上限を設けておけば、フレームが途切れた直後でも積分量が一度に膨らまず、盤の位置が飛ばずに済みます。
重み付きマスは角度自体を確率にする
確率%のテキストだけを大きく見せて角度を均等のままにすると、見た目と実際の当選しやすさが食い違う偽の抽選になってしまいます。
自動演示中のティック音は鳴らさない
シード固定の自動ループにまで音を乗せると、無音のはずのgif・mp4キャプチャに音があるふりをすることになるので、drag.onの条件を必ず挟みます。パスワード付きzipには9項目ぶんの完成コードがまとまっていて、本文内のsgvkvgusがその解凍キーです。
摩擦係数ひとつで手触りが変わる3つの変化とアクセシビリティ
同じ盤でも1つの数値を変えるだけで手触りが大きく変わります。3つのバリエーションと、動きを抑えたいユーザーへの対応をまとめます。
| 項目 | 変える値 | 効果 |
|---|---|---|
| 03 摩擦減衰 | 摩擦係数 0.38 → 0.08 | 長く回る代わりに待ち時間が伸びる |
| 01 扇形ホイール | マス数 8 → 18 | ティックが密になるが文字が小さくなる |
| 05 ポインターのカチッ | ポインター振幅 4px → 7px | 手触りは増すが判定角とずれて見えやすい |
摩擦係数0.38から0.08へ — 待ち時間と手応えのトレードオフ
摩擦を弱めるとしきい値を下回るまでの時間が伸びて盤が長く回り続け、逆に強めると背筋が伸びるほどぴたりと止まりますが、はじく楽しさそのものは減ります。
マス数8から18へ — ティックの密度と文字サイズのトレードオフ
マス数を増やすと境界を通る回数が増えてティック音が密になり速度感が増しますが、1マスの弧長が短くなる分だけ表示できる文字が小さくなります。
ポインター振幅4pxから7pxへ — 手触りと判定角のずれのトレードオフ
振幅を大きくすると実物のピンに掛かるような手触りが増しますが、行き過ぎるとポインターの見た目の位置と判定角度がずれて見え、計算が間違っているように感じられます。
prefers-reduced-motionは静止した結果画面を1枚固定する
@media (prefers-reduced-motion: reduce)が真の環境では、rAFループそのものを起動せず、結果がすでに出た状態の盤を1枚描いて固定します。空のキャンバスに戻すのではなく、静止した完成状態を見せる点がポイントです。
発表の順番決めと景品抽選での使い方
社内発表や授業の指名で同じ人が二度当たらないようにしたいときは08連続抽選が向いています。一度当たった名前は配列から外れ、誤操作は取り消しで戻せるので、進行を止めずに済みます。
景品を配るイベントでは09ヒーローシーンのように当選の瞬間に紙吹雪が弾け、結果をtoDataURLで画像保存できる盤がその場の記念にもなります。参加者名簿を当日差し替えるなら07項目エディタが名前の増減に合わせて角度とフォントサイズを自動で計算し直します。
名前抽選や当選確率についてよくある質問
web ルーレットの当選確率は操作で変えられますか
いいえ。当選インデックスは停止角度を−π/2基準で正規化してマス角(または累積比率のbounds)で割った計算結果であり、後から任意のマスを選んで表示する経路はどこにも存在しません。
名前 ルーレットとして使えますか
はい。07項目エディタでは名前を入力するたびにmeasureTextで幅を測ってフォントサイズを二分探索で縮め、行の増減に合わせて角度分割も再計算するので、参加者名を入れ替えるだけで名前抽選盤として使えます。
javascript ルーレットとして実装をそのまま流用できますか
はい。9項目ともライブラリ・CDNを使わないバニラJavaScriptで、zipにはReact 18版も同梱しているので、どちらの環境でも角速度の積分ループをそのままコピーして動かせます。
もっと読む
同じ「毎フレーム計算し直す」考え方は他のキャンバス実装にも共通しています。衝突判定で崩れないテトリスの9ステップや、ピクセル単位で砂の落下を計算するキャンバス9種もあわせてご覧ください。









