04값 비율만큼 조각이 늘어나는 도넛 피커
선택한 값을 전체 합으로 나눈 비율을 도넛 조각의 각도로 바꾸고, 다른 조각들의 각도도 함께 다시 계산해 줄여 전체 원이 항상 360도를 유지하게 한다.
쓰임예산 배분·시간 배분처럼 합이 고정된 값 입력
LABELSOTHER_RATIOSTROKE

<main class="stage">
<figure class="dn is-demo" id="dn">
<div class="dn__zone" id="zone">
<svg class="dn__chart" id="chart" viewBox="0 0 480 300" role="img" aria-label="네 조각으로 나뉜 도넛 그래프, 첫 조각의 비율을 조정하면 나머지 세 조각도 남는 비율에 맞춰 함께 다시 계산된다">
<path class="dn__seg dn__seg--0" id="seg0"></path>
<path class="dn__seg dn__seg--1" id="seg1"></path>
<path class="dn__seg dn__seg--2" id="seg2"></path>
<path class="dn__seg dn__seg--3" id="seg3"></path>
<text class="dn__num" id="num" x="240" y="150" text-anchor="middle" dominant-baseline="central">40%</text>
<text class="dn__cap" id="cap" x="240" y="176" text-anchor="middle" dominant-baseline="central">A 조각</text>
</svg>
<b class="dn__badge" aria-hidden="true">A <span id="dn-val">40</span>%</b>
</div>
<div class="dn__track" id="track">
<span class="dn__rail" aria-hidden="true"></span>
<button class="dn__grab" id="grab" type="button" role="slider" aria-orientation="horizontal"
aria-label="A 조각 비율 고르기" aria-valuemin="10" aria-valuemax="70"
aria-valuenow="40" aria-valuetext="A 조각 40%">
<svg class="dn__ic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M96 64v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0m88 0v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0" opacity=".2"/><path d="M188 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8M76 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8"/></g></svg>
</button>
</div>
<p class="dn__hint">막대 위를 드래그하거나 화살표키를 누르면 나머지 조각의 비율이 함께 다시 계산됩니다</p>
<div class="sr">
<span aria-live="polite" id="dn-live"></span>
<table>
<caption>조각별 비율</caption>
<thead><tr><th>조각</th><th>비율</th></tr></thead>
<tbody id="sr-body"></tbody>
</table>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 도넛 세그먼트 피커 — 드래그·키보드로 A 조각 비율(10~70)을 정하면 남는 비율을
// B·C·D 조각이 고정 가중치로 나눠 갖고, 4개 조각의 시작각~끝각(path d 속성)을
// 매 프레임 다시 계산해 그린다. 관성 lerp 로 값이 목표를 뒤따른다.
// CSS @keyframes 는 0개(네 조각 모두 매 프레임 실수 각도로 바뀌어 선언형으로 못 담는다,
// 398-01/02/03 과 같은 이유).
// picker-graph-donut-radius(JS, R)가 도넛 반지름을 정한다.
$duration: $dur-loop;
$easing: $ease-lerp-settle;
$color: $subject-blue;
@include stage($stage-paper);
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.dn {
box-sizing: border-box;
width: min(340px, 92vw);
margin: 0; padding: $sp-1;
display: flex; flex-direction: column; gap: $sp-1; align-items: center;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.dn__zone {
position: relative;
width: min(296px, 100%);
aspect-ratio: 480 / 300;
}
.dn__chart { display: block; width: 100%; height: 100%; overflow: visible; }
.dn__seg { fill: none; stroke-width: 46; stroke-linecap: butt; }
.dn__seg--0 { stroke: $subject-blue-deep; }
.dn__seg--1 { stroke: $chart-2; }
.dn__seg--2 { stroke: $chart-3; }
.dn__seg--3 { stroke: $chart-4; }
.dn__num { font-size: 40px; font-weight: 800; font-variant-numeric: tabular-nums; fill: $subject-ink; }
.dn__cap { font-size: 13px; font-weight: 700; fill: rgba($subject-ink, .62); }
.dn__badge {
position: absolute; left: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $stage-paper; font-weight: 700; font-size: 11px;
font-variant-numeric: tabular-nums;
}
.dn__track {
position: relative;
width: 100%;
height: 24px;
margin: 0 $sp-1;
border-radius: $r-pill;
background: mix($subject-blue, $stage-paper, 14%);
}
.dn__rail {
position: absolute; left: $sp-3; right: $sp-3; top: 50%;
height: 3px; transform: translateY(-50%);
border-radius: $r-pill;
background: rgba($subject-ink, .18);
}
.dn__grab {
position: absolute; top: 50%; left: 50%;
width: 26px; height: 26px;
margin: 0; padding: 0; border: 0; border-radius: 50%;
display: grid; place-items: center;
background: $subject-blue-deep;
color: $stage-paper;
transform: translate(-50%, -50%);
cursor: ew-resize;
touch-action: none;
}
.dn__grab:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.dn__ic { width: 13px; height: 13px; }
.dn__hint { margin: 0; padding: 0 $sp-1; font-size: 11px; line-height: 16px; color: rgba($subject-ink, .62); text-align: center; }
@media (max-width: 340px) {
.dn { padding: $sp-2; gap: $sp-1; }
.dn__hint { display: none; }
.dn__zone { width: min(200px, 100%); }
.dn__num { font-size: 30px; }
.dn__seg { stroke-width: 30; }
.dn__grab { width: 22px; height: 22px; }
.dn__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — rAF 루프를 시작하지 않는다. 입력은 current=value 즉시 반영 후 paint() 1회.
@media (prefers-reduced-motion: reduce) {
.dn.is-still .dn__seg { transition: none !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);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.dn {
box-sizing: border-box;
width: min(340px, 92vw);
margin: 0;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.dn__zone {
position: relative;
width: min(296px, 100%);
aspect-ratio: 480/300;
}
.dn__chart {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.dn__seg {
fill: none;
stroke-width: 46;
stroke-linecap: butt;
}
.dn__seg--0 {
stroke: #0a4fe8;
}
.dn__seg--1 {
stroke: #c2361a;
}
.dn__seg--2 {
stroke: #2b9d74;
}
.dn__seg--3 {
stroke: #8a5cd6;
}
.dn__num {
font-size: 40px;
font-weight: 800;
font-variant-numeric: tabular-nums;
fill: #17141a;
}
.dn__cap {
font-size: 13px;
font-weight: 700;
fill: rgba(23, 20, 26, 0.62);
}
.dn__badge {
position: absolute;
left: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #ffffff;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.dn__track {
position: relative;
width: 100%;
height: 24px;
margin: 0 4px;
border-radius: 999px;
background: rgb(88.5803921569%, 91.9843137255%, 99.5058823529%);
}
.dn__rail {
position: absolute;
left: 12px;
right: 12px;
top: 50%;
height: 3px;
transform: translateY(-50%);
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.dn__grab {
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #0a4fe8;
color: #ffffff;
transform: translate(-50%, -50%);
cursor: ew-resize;
touch-action: none;
}
.dn__grab:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.dn__ic {
width: 13px;
height: 13px;
}
.dn__hint {
margin: 0;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: rgba(23, 20, 26, 0.62);
text-align: center;
}
@media (max-width: 340px) {
.dn {
padding: 8px;
gap: 4px;
}
.dn__hint {
display: none;
}
.dn__zone {
width: min(200px, 100%);
}
.dn__num {
font-size: 30px;
}
.dn__seg {
stroke-width: 30;
}
.dn__grab {
width: 22px;
height: 22px;
}
.dn__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dn.is-still .dn__seg {
transition: none !important;
}
}// 실제 동작: pointerdown/move/up 과 keydown 이 A 조각 비율(10~70)을 바꾸면, 남는 비율
// (100-A)을 B·C·D 조각의 고정 가중치로 나눠 각 조각의 시작각~끝각을 다시 계산하고
// 4개 도넛 조각(path d 속성)을 매 프레임 새로 그린다. 값은 관성 lerp 로 목표를 뒤따른다.
// CSS @keyframes 없음(네 조각 모두가 매 프레임 실수 각도로 바뀌어 선언형으로 못 담는다,
// 398-01/02/03 과 같은 이유).
var root = document.getElementById('dn');
var zone = document.getElementById('zone');
var track = document.getElementById('track');
var grab = document.getElementById('grab');
var segEls = [document.getElementById('seg0'), document.getElementById('seg1'), document.getElementById('seg2'), document.getElementById('seg3')];
var numEl = document.getElementById('num');
var capEl = document.getElementById('cap');
var valEl = document.getElementById('dn-val');
var live = document.getElementById('dn-live');
var srBody = document.getElementById('sr-body');
var CX = 240, CY = 150, R = 104; // picker-graph-donut-radius — 도넛 반지름
var STROKE = 46;
var OTHER_RATIO = [0.5, 0.3, 0.2]; // B·C·D 가 남는 비율을 나누는 고정 가중치
var LABELS = ['A 조각', 'B 조각', 'C 조각', 'D 조각'];
var MIN_V = 10, MAX_V = 70;
var LERP_RATE = 0.20;
var AUTO_PERIOD = 1.8;
var STEP = 5;
var value = 40;
var current = 40;
var manual = false;
var autoT = 0;
var lastTs = 0;
var zoneRect = null;
var rafId = 0;
var pct = [40, 30, 18, 12];
function reduced() {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function polar(cx, cy, r, deg) {
var rad = deg * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx, cy, r, a0, a1) {
if (a1 - a0 < 0.6) return '';
var s = polar(cx, cy, r, a0), e = polar(cx, cy, r, a1);
var large = (a1 - a0) > 180 ? 1 : 0;
return 'M ' + s.x + ' ' + s.y + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + e.x + ' ' + e.y;
}
function recompute(v) {
var rest = 100 - v;
pct = [v, rest * OTHER_RATIO[0], rest * OTHER_RATIO[1], rest * OTHER_RATIO[2]];
}
function paint() {
recompute(current);
var start = -90, a = start;
for (var i = 0; i < 4; i++) {
var sweep = pct[i] * 3.6;
var a1 = a + sweep;
segEls[i].setAttribute('d', arcPath(CX, CY, R, a, a1));
a = a1;
}
var rv = Math.round(current);
numEl.textContent = rv + '%';
capEl.textContent = LABELS[0];
valEl.textContent = String(rv);
grab.style.left = ((value - MIN_V) / (MAX_V - MIN_V) * 100) + '%';
grab.setAttribute('aria-valuenow', String(rv));
grab.setAttribute('aria-valuetext', 'A 조각 ' + rv + '%');
var rows = '';
for (var j = 0; j < 4; j++) rows += '<tr><th>' + LABELS[j] + '</th><td>' + Math.round(pct[j]) + '%</td></tr>';
srBody.innerHTML = rows;
}
function announce() {
live.textContent = 'A 조각 ' + Math.round(current) + '%로 다시 계산됨';
}
function setValue(v, isManual) {
value = Math.max(MIN_V, Math.min(MAX_V, v));
if (isManual) { manual = true; root.classList.remove('is-demo'); }
}
function fromPointer(clientX) {
if (!zoneRect) zoneRect = track.getBoundingClientRect();
var ratio = (clientX - zoneRect.left) / zoneRect.width;
setValue(MIN_V + ratio * (MAX_V - MIN_V), true);
}
function onDown(e) {
zoneRect = track.getBoundingClientRect();
if (grab.setPointerCapture) { try { grab.setPointerCapture(e.pointerId); } catch (err) {} }
fromPointer(e.clientX);
if (reduced()) { current = value; paint(); announce(); }
}
function onMove(e) { fromPointer(e.clientX); if (reduced()) { current = value; paint(); announce(); } }
function onUp() { announce(); }
function onTrackDown(e) { zoneRect = track.getBoundingClientRect(); fromPointer(e.clientX); if (reduced()) { current = value; paint(); announce(); } }
function onKey(e) {
var map = { ArrowLeft: -STEP, ArrowDown: -STEP, ArrowRight: STEP, ArrowUp: STEP };
if (e.key === 'Home') { setValue(MIN_V, true); e.preventDefault(); }
else if (e.key === 'End') { setValue(MAX_V, true); e.preventDefault(); }
else if (map[e.key] !== undefined) { setValue(value + map[e.key], true); e.preventDefault(); }
else { return; }
if (reduced()) { current = value; paint(); announce(); }
}
function onResize() { zoneRect = track.getBoundingClientRect(); if (reduced()) paint(); }
function autoStep(dt) {
autoT += dt;
var omega = 2 * Math.PI / AUTO_PERIOD;
value = 40 + 26 * Math.sin(autoT * omega);
}
function frame(ts) {
if (!lastTs) lastTs = ts;
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (!manual) autoStep(dt);
current += (value - current) * LERP_RATE;
paint();
rafId = requestAnimationFrame(frame);
}
grab.addEventListener('pointerdown', onDown);
grab.addEventListener('pointermove', onMove);
grab.addEventListener('pointerup', onUp);
grab.addEventListener('keydown', onKey);
track.addEventListener('pointerdown', onTrackDown);
window.addEventListener('resize', onResize);
if (reduced()) {
root.classList.add('is-still');
current = value;
paint();
} else {
paint();
rafId = requestAnimationFrame(frame);
}// DonutSegmentResize — vanilla 판(run/398/demo/04-picker-graph-donut-segment-resize/index.html) 의 React 포트.
// A 조각 비율(10~70)을 드래그·키보드로 정하면 남는 비율을 B·C·D 조각이 고정 가중치로
// 나눠 갖고, 4개 조각의 시작각~끝각(path d 속성)을 매 프레임 다시 계산해 그린다.
import { useEffect, useRef } from "react";
import styles from "./DonutSegmentResize.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const CX = 240, CY = 150, R = 104; // picker-graph-donut-radius — 도넛 반지름
const OTHER_RATIO = [0.5, 0.3, 0.2];
const LABELS = ["A 조각", "B 조각", "C 조각", "D 조각"];
const MIN_V = 10, MAX_V = 70;
const LERP_RATE = 0.20;
const AUTO_PERIOD = 1.8;
const STEP = 5;
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = (deg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx: number, cy: number, r: number, a0: number, a1: number): string {
if (a1 - a0 < 0.6) return "";
const s = polar(cx, cy, r, a0), e = polar(cx, cy, r, a1);
const large = a1 - a0 > 180 ? 1 : 0;
return "M " + s.x + " " + s.y + " A " + r + " " + r + " 0 " + large + " 1 " + e.x + " " + e.y;
}
export default function DonutSegmentResize() {
const rootRef = useRef<HTMLElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const grabRef = useRef<HTMLButtonElement | null>(null);
const segRefs = useRef(new Array(4).fill(null)) as Box<(SVGPathElement | null)[]>;
const numRef = useRef<SVGTextElement | null>(null);
const capRef = useRef<SVGTextElement | null>(null);
const valRef = useRef<HTMLElement | null>(null);
const liveRef = useRef<HTMLSpanElement | null>(null);
const srBodyRef = useRef<HTMLTableSectionElement | null>(null);
const value = useRef(40) as Box<number>;
const current = useRef(40) as Box<number>;
const manual = useRef(false) as Box<boolean>;
const autoT = useRef(0) as Box<number>;
const lastTs = useRef(0) as Box<number>;
const trackRect = useRef<DOMRect | null>(null) as Box<DOMRect | null>;
const rafId = useRef(0) as Box<number>;
const pct = useRef([40, 30, 18, 12]) as Box<number[]>;
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function recompute(v: number): void {
const rest = 100 - v;
pct.current = [v, rest * OTHER_RATIO[0], rest * OTHER_RATIO[1], rest * OTHER_RATIO[2]];
}
function paint(): void {
recompute(current.current);
const start = -90;
let a = start;
for (let i = 0; i < 4; i++) {
const sweep = pct.current[i] * 3.6;
const a1 = a + sweep;
segRefs.current[i]?.setAttribute("d", arcPath(CX, CY, R, a, a1));
a = a1;
}
const rv = Math.round(current.current);
if (numRef.current) numRef.current.textContent = rv + "%";
if (capRef.current) capRef.current.textContent = LABELS[0];
if (valRef.current) valRef.current.textContent = String(rv);
const grab = grabRef.current;
if (grab) {
grab.style.left = ((value.current - MIN_V) / (MAX_V - MIN_V)) * 100 + "%";
grab.setAttribute("aria-valuenow", String(rv));
grab.setAttribute("aria-valuetext", "A 조각 " + rv + "%");
}
if (srBodyRef.current) {
let rows = "";
for (let j = 0; j < 4; j++) rows += "<tr><th>" + LABELS[j] + "</th><td>" + Math.round(pct.current[j]) + "%</td></tr>";
srBodyRef.current.innerHTML = rows;
}
}
function announce(): void {
if (liveRef.current) liveRef.current.textContent = "A 조각 " + Math.round(current.current) + "%로 다시 계산됨";
}
function setValue(v: number, isManual: boolean): void {
value.current = Math.max(MIN_V, Math.min(MAX_V, v));
if (isManual) {
manual.current = true;
rootRef.current?.classList.remove("is-demo");
}
}
function fromPointer(clientX: number): void {
if (!trackRect.current && trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
const rect = trackRect.current;
if (!rect) return;
const ratio = (clientX - rect.left) / rect.width;
setValue(MIN_V + ratio * (MAX_V - MIN_V), true);
}
function reflectNow(): void {
current.current = value.current;
paint();
announce();
}
function onDown(e: PtrEvt): void {
if (trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
const grab = grabRef.current;
if (grab && typeof grab.setPointerCapture === "function") {
try { grab.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 치명적이지 않다 */ }
}
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onMove(e: PtrEvt): void {
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onUp(): void {
announce();
}
function onTrackDown(e: PtrEvt): void {
if (trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onKey(e: KeyEvt): void {
const map: Record<string, number> = { ArrowLeft: -STEP, ArrowDown: -STEP, ArrowRight: STEP, ArrowUp: STEP };
if (e.key === "Home") { setValue(MIN_V, true); e.preventDefault(); }
else if (e.key === "End") { setValue(MAX_V, true); e.preventDefault(); }
else if (map[e.key] !== undefined) { setValue(value.current + map[e.key], true); e.preventDefault(); }
else { return; }
if (reduced()) reflectNow();
}
function onResize(): void {
if (trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
if (reduced()) paint();
}
function autoStep(dt: number): void {
autoT.current += dt;
const omega = (2 * Math.PI) / AUTO_PERIOD;
value.current = 40 + 26 * Math.sin(autoT.current * omega);
}
function frame(ts: number): void {
if (!lastTs.current) lastTs.current = ts;
const dt = Math.min(0.05, (ts - lastTs.current) / 1000);
lastTs.current = ts;
if (!manual.current) autoStep(dt);
current.current += (value.current - current.current) * LERP_RATE;
paint();
rafId.current = requestAnimationFrame(frame);
}
useEffect(() => {
window.addEventListener("resize", onResize);
if (reduced()) {
rootRef.current?.classList.add("is-still");
reflectNow();
} else {
paint();
rafId.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafId.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<figure className={styles.dn + " is-demo"} ref={rootRef}>
<div className={styles.zone}>
<svg
className={styles.chart}
viewBox="0 0 480 300"
role="img"
aria-label="네 조각으로 나뉜 도넛 그래프, 첫 조각의 비율을 조정하면 나머지 세 조각도 남는 비율에 맞춰 함께 다시 계산된다"
>
{[0, 1, 2, 3].map((i) => (
<path
key={i}
className={styles.seg + " " + (styles as Record<string, string>)["seg" + i]}
ref={(el: SVGPathElement | null) => { segRefs.current[i] = el; }}
/>
))}
<text className={styles.num} ref={numRef} x={240} y={150} textAnchor="middle" dominantBaseline="central">40%</text>
<text className={styles.cap} ref={capRef} x={240} y={176} textAnchor="middle" dominantBaseline="central">A 조각</text>
</svg>
<b className={styles.badge} aria-hidden="true">A <span ref={valRef}>40</span>%</b>
</div>
<div className={styles.track} ref={trackRef} onPointerDown={onTrackDown}>
<span className={styles.rail} aria-hidden="true" />
<button
className={styles.grab}
ref={grabRef}
type="button"
role="slider"
aria-orientation="horizontal"
aria-label="A 조각 비율 고르기"
aria-valuemin={MIN_V}
aria-valuemax={MAX_V}
aria-valuenow={40}
aria-valuetext="A 조각 40%"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onKeyDown={onKey}
>
<svg className={styles.ic} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M96 64v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0m88 0v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0" opacity=".2" /><path d="M188 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8M76 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8" /></g></svg>
</button>
</div>
<p className={styles.hint}>막대 위를 드래그하거나 화살표키를 누르면 나머지 조각의 비율이 함께 다시 계산됩니다</p>
<div className={styles.sr}>
<span aria-live="polite" ref={liveRef} />
<table>
<caption>조각별 비율</caption>
<thead><tr><th>조각</th><th>비율</th></tr></thead>
<tbody ref={srBodyRef} />
</table>
</div>
</figure>
</main>
);
}04 값 비율만큼 조각이 늘어나는 도넛 피커 — 측정 누적면적 12.204% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 그래프 애니메이션 css 피커 9가지 — 값 하나로 다시 그리기
이 데모가 속한 글
그래프 애니메이션 css 피커 9가지 — 값 하나로 다시 그리기그래프 애니메이션 css 아홉 개를 나란히 두고 보면 막대를 픽셀 위치로만 옮기면 그래프가 실제 값과 어긋난다 — 그래서 값 하나로 히스토그램 막대 높이를 다시 계산하는 피커, 원호 길이를 채우는 게이지 피커, 지나 이 글의 데모 전부 보기


