04Weighted odds — splitting sector angles by weight for a fair draw
Equal sectors give equal odds, but give each entry a weight and its start/end angles follow cumulative shares — the angles themselves become the probability. The result is judged by resting angle after the stop, so what you see is exactly what you get, rigged nothing.
Best forPrize draws, team picks
Math.randomfillTextarc

<main class="stage">
<div class="wt" id="wt">
<p class="wt__hint">The sector angle itself is the probability — weights split start and end angles by cumulative share</p>
<div class="wt__row">
<div class="wt__field" id="field" role="application" aria-label="Weighted odds lab" 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">expected odds of the sector under the pointer</span>
<p class="wt__meta">draws <b class="wt__count" id="tries">0</b><span class="wt__metaUnit">runs</span></p>
<p class="wt__meta">Seeded mulberry32 — the same family of results every spin</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) — 모든 칸을 같은 각도로 그리면 sectors 수만큼 균등 확률이 되지만,
// 항목마다 무게를 두면 시작각·끝각을 누적 비율로 잡아 각도 자체가 확률이 된다.
// 결과는 멈춘 뒤 각도로 판정하므로 조작 없이 눈에 보이는 확률 그대로 당첨된다.
// 난수는 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; }
// Resting angle → 부채꼴 인덱스 (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();
// 무게 라벨 — sectors 가운데 각도에 확률 % 를 적는다
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 Weighted odds — splitting sector angles by weight for a fair draw — Measured area 22.052% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Spin Wheel: 9 Canvas Roulettes, Flick to Stop
The post this demo lives in
Spin Wheel: 9 Canvas Roulettes, Flick to StopIt spins exactly as hard as you flick it. Angular velocity is integrated, friction decays it, and the sector under the p See all demos in this post
If you like this effect - similar demos
game04 A Board Where Hovering a Cell Spreads a Ring of Win, Draw and Loss Shares Around a Ghost MarkCSS Tic Tac Toe: 9 Boards That Count Ahead
game08 Conic roulette spinHTML CSS JS mini games: 9 copy-paste mini apps
game01 Basic Spinning Top Whose Angular Speed Decays by FrictionSpinning Top Animation: 9 Canvas Physics Scenes