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="4つの断片に分かれたドーナツグラフ。最初の断片の比率を調整すると、残り3つの断片も余った比率に合わせて再計算される。">
<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 オリジナル · SCSS と React は zip に同梱 · グラフ アニメーション css ピッカー9パターン
このデモが属する記事
グラフ アニメーション css ピッカー9パターンバーをピクセル位置だけで動かすとグラフが値とずれる — だから値ひとつで棒の高さを再計算するピッカー、比率で弧の長さを満たすゲージピッカー、通った値を折れ線に残すスパークラインピッカーまで九つを見る。これが グラフ アニメーション css この記事のデモをすべて見る


