07항목 편집기 — 이름 줄과 칸 각도가 함께 다시 계산되는 판
이름을 치면 measureText 로 폭을 재고 칸 호 길이에 맞게 글자 크기를 이분 탐색으로 줄입니다. 줄이 늘고 줄어들 때마다 각도 분할과 배치를 전부 다시 계산해 판이 편집 내용을 그대로 따라갑니다.
쓰임커스텀 뽑기 도구, 프로토타입
fillTextmeasureTextchange

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


