07The entry editor — a wheel whose labels and angles recompute together
Type a name and measureText checks its width, binary-searching the font size down until it fits the arc. Every add or remove recomputes the whole division and layout, so the disc tracks your edit live.
Best forCustom draw tools, prototypes
fillTextmeasureTextchange

<main class="stage">
<div class="ed" id="ed">
<p class="ed__hint">Edit a name and sector angles and font size recompute together — measureText binary search</p>
<div class="ed__row">
<div class="ed__field" id="field" role="application" aria-label="Entry editor lab" 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">Add a sector</button>
<p class="ed__meta">Now <b class="ed__count" id="cnt">4</b><span class="ed__metaUnit">sectors</span></p>
<p class="ed__meta">Drag to spin and release</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 로 폭을 재고 sectors 호 길이에 맞게 글자 크기를
// 이분 탐색으로 줄인다. 줄이 늘고 줄어들 때마다 각도 분할과 배치를 전부 다시 계산해
// 판이 편집 내용을 그대로 따라간다.
(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 = ['Haneul Kim', 'Junho Lee', 'Seoyeon Park', 'Doyun Choi'];
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();
// Name — 호 길이(반지름 × 중심각, 여유 .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) + 'entry name');
inp.addEventListener('change', function () {
entries[idx] = inp.value || 'Name';
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('New entry');
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 = ['Haneul Kim', 'Junho Lee', 'Seoyeon Park', 'Doyun Choi', 'Minjun Jung', 'Sua Han', 'New entry'];
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 The entry editor — a wheel whose labels and angles recompute together — Measured area 21.903% · 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
game07 A Snake Skin Whose Scale Pattern and Brightness Wave Flow Along the BodyCSS Snake Game: 9 Boards That Find Their Own Path
game06 Ghost piece and hard drop — precomputing the landing spotTetris JavaScript: Build It in 9 Canvas Steps
game02 Ball Falling Under Gravity and Settling on the Floor9 Bouncing Ball Animation Patterns With Physics