01扇形ホイール — 極座標の弧でマスをつなぐ盤
moveTo(中心) → arc(開始角, 終了角) → closePath でマスを1つ描き、マス数だけ繰り返せば盤の土台が完成です。角度は2π/nで均等分割し、色はパレット配列を循環させて隣り合うマスが同色にならないようにします。
使いどころ抽選ツール、イベントページ
arcbeginPathfillText

<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 に同梱 · web ルーレット — 弾いて回し摩擦で止まる回転盤キャンバス9種
このデモが属する記事
web ルーレット — 弾いて回し摩擦で止まる回転盤キャンバス9種web ルーレットを指ではじいて遊びたいなら、まず計算の中身を見せます。指ではじいた強さだけ回ります。角速度を積分し摩擦で減衰し、ポインターの下のマスを計算で判定します。重み付き確率・連続抽選・当たりの紙吹雪まで — キャンバス1枚、コード この記事のデモをすべて見る


