원형 그래프 만들기 9종 — 조각이 튀어나오고 3D로 기우는 계산
원형 그래프는 다 그려진 뒤의 움직임에서 읽히는 정도가 갈린다 — 02번은 마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오고, 03번은 원판을 기울여 옆면을 뒤에서부터 칠하며, 06번은 원이 풀려 막대 그래프로 바뀐다.
가상 데이터로 그린 원형 그래프 아홉 개, 조각을 직접 건드려 보기
01 pie-chart-sweep-stagger-draw
02 pie-chart-slice-pop-spring
03 pie-chart-3d-tilt-side-wall
04 pie-chart-data-morph-angle-lerp
05 pie-chart-label-leader-relax
06 pie-chart-unroll-to-bars
07 pie-chart-sunburst-drill-ring
08 pie-chart-donut-hole-spring-count
09 pie-chart-pointer-light-grain
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 조각마다 시차를 두고 시계 방향으로 쓸며 그려지는 원형 그래프
- 02 마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오는 원형 그래프
- 03 원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트
- 04 기간을 바꾸면 조각 각도가 이어지며 새 비율로 바뀌는 원형 그래프
- 05 바깥 라벨이 지시선을 달고 겹치면 위아래로 밀려나는 원형 그래프
- 06 원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환
- 07 안쪽 원과 바깥 고리로 나뉘고 누르면 그 갈래가 한 바퀴로 펼쳐지는 선버스트
- 08 가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 전환
- 09 포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프 장면
원형 그래프 만들기 9종 — 쓸기·튀어나옴·3D 기울기·비율 변환·라벨 밀어내기·막대 풀기·선버스트·도넛 구멍·빛 결
장면 순서는 원이 처음 그려지는 순간(01)에서 출발해 조각 하나를 골라 띄우는 손짓(02·03), 숫자가 바뀌거나 이름표가 붐빌 때의 처리(04·05), 원이 다른 모양으로 넘어가는 전환(06·07·08)을 거쳐 원판 표면을 화소마다 칠하는 장면(09)으로 끝난다. 아홉 개 모두 <canvas> 2D 하나에 그리고, 스타일시트는 무대 비율 480:300 과 버튼, 포커스 테두리만 맡는다. @keyframes 는 쓰지 않았고 버튼 배경색 전환 하나를 빼면 모든 움직임을 requestAnimationFrame 이 16ms 고정 스텝으로 돌린다.
숫자는 시드 하나로 뽑은 가상 값
그래프에 들어가는 값은 실제 통계가 아니라 32비트 정수를 섞는 makeRng(seed) 가 만든 가상 숫자이고, 조각 이름도 「항목 A」부터 「항목 I」까지의 자리 표시뿐이다. 기본 시드가 467 이라 01 의 일곱 값은 12·40·10·24·38·12·34, 합계 170 으로 정해져 나오고, 「새 데이터」 를 누르면 시드가 1 올라 다른 값이 된다. 아홉 index.html 어디에도 Math.random·Date.now·fetch·localStorage 가 없으니, 이 글에 적은 비율과 시각은 같은 시드로 돌리면 똑같이 다시 나온다.
480×300 창에서 2초 동안 24장을 찍어 비교한 움직임
장면마다 2초 동안 24장을 찍고 이웃한 두 장끼리 비교했으므로 움직인 프레임 칸의 분모는 23 이다.
| 장면 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 시차 쓸기 | 22.6% | 122.5 | 19/23 |
| 02 튀어나옴 스프링 | 25.6% | 84.0 | 23/23 |
| 03 3D 기울기 옆면 | 19.7% | 85.1 | 23/23 |
| 04 기간 전환 각도 보간 | 23.7% | 84.4 | 20/23 |
| 05 라벨 밀어내기 | 5.3% | 90.6 | 18/23 |
| 06 원에서 막대로 | 26.3% | 156.5 | 19/23 |
| 07 선버스트 펼치기 | 26.0% | 79.8 | 18/23 |
| 08 도넛 구멍과 합계 세기 | 22.9% | 103.2 | 22/23 |
| 09 포인터 빛과 결 | 22.0% | 26.6 | 23/23 |
05 의 누적면적만 5.3% 로 낮은 까닭은 원은 제자리에 있고 바깥 라벨과 지시선만 위아래로 옮겨 가는 장면이어서다. 01 은 200ms 에 쓸기를 시작해 1,580ms 에 다 그린 뒤 다음 회차까지 서 있고, 05 와 07 도 밀기나 펼치기가 끝난 구간에서 화면이 멈춰 18장에 그친다. 09 는 빛이 옮겨 갈 때 넓은 면의 밝기가 조금씩 바뀌는 계산이라 면적에 비해 강도 평균이 26.6 으로 낮다.
원형 그래프 디자인을 고를 때 어울리는 자리
보고서 첫 장이나 설문 결과 카드처럼 비율을 한 번 보여 주고 끝나는 곳이라면 01 의 시차 쓸기로 충분하고, 대시보드에서 조각을 짚어 보게 하려면 02 의 튀어나옴이 바탕이 된다. 발표 자료 표지처럼 입체감이 필요한 그래프는 03, 월이나 분기마다 구성비가 달라지는 카드는 04 가 맡는다. 항목이 많아 이름표가 겹치는 리포트는 05, 크기 비교를 같은 카드에서 바꿔 보여 줄 때는 06, 카테고리 아래 하위 카테고리가 딸린 구성비는 07 이 들어간다. 08 은 KPI 합계를 가운데 크게 두는 카드, 09 는 랜딩 페이지의 지표 장면처럼 그래프를 장식으로도 쓰는 자리에 어울린다.
숫자 목록은 어떻게 부채꼴의 시작 각과 끝 각이 되는가?
원형 그래프의 각은 「값 ÷ 합계 × 2π」 하나로 정해진다. 아래 layout 은 값을 앞에서부터 누적하며 누적합을 합계로 나눈 몫에 2π 를 곱해, 조각마다 시작 각 a0, 끝 각 a1, 비율 share 를 만든다. 앞 조각의 a1 이 곧 다음 조각의 a0 이어서 틈이나 겹침이 생길 자리가 없고, 마지막 조각은 누적합이 합계와 같아지는 순간이라 정확히 2π 에서 닫힌다. 각의 원점 TOP 은 −π/2, 곧 12시이고 시계 방향으로 늘어나며, dirX·dirY 가 이 각에 TOP 을 더해 화면의 코사인·사인으로 바꾼다.
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
01 의 시드 467 값을 넣으면 합계 170 에 대해 비율이 7.1·23.5·5.9·14.1·22.4·7.1·20.0% 로 나온다. 04·07 을 뺀 일곱 장면이 이 함수로 각을 만들고, 04 는 항목 순서를 고정한 누적을 anglesOf 에서 따로 하며, 07 은 부모 범위를 자식 비율로 다시 나누는 split 을 쓴다. 검색에서는 파이 차트 도넛 차트를 따로 찾게 되지만 이 소스에서 둘은 wedgePath 에 넘기는 안쪽 반지름 하나 차이다. 안쪽 반지름이 0.5px 이하면 가운데 점까지 닫힌 부채꼴을, 그보다 크면 도넛 조각을 그린다.
조각이 튀어나오는 방향은 왜 그 조각 가운데 각의 코사인·사인인가
조각의 가운데 각 midOf(i) = (a0 + a1) ÷ 2 는 그 부채꼴을 좌우로 똑같이 가르는 선의 방향이다. 02 는 조각 전체를 (dirX(m), dirY(m)) 쪽으로 x 픽셀 옮겨 그리는데, 이 방향은 길이 1 인 단위 벡터라 x 가 그대로 이동 거리가 되고 조각은 원 중심에서 곧장 바깥으로 멀어지며 양옆 이웃과 같은 폭의 틈을 벌린다. 가운데 선이 아닌 쪽으로 밀면 한쪽 이웃과는 틈이 벌어지고 반대쪽 이웃 위로는 올라탄다.
거꾸로 포인터가 어느 조각 위에 있는지는 그 점의 각을 구해 시작 각과 끝 각 사이에 드는지로 판정한다. 아래 angleAt 은 Math.atan2(y − cy, x − cx) 로 얻은 화면 각에서 TOP 을 빼고 0~2π 로 옮겨 layout 의 각과 기준을 맞춘다. 아홉 장면 모두 조각을 고를 때 이 각 하나를 쓴다. 03 은 기울어진 원판의 투영을 거꾸로 풀어 평면 좌표로 돌린 뒤 이 각을 구하고, 07 은 구한 각을 보고 있는 범위의 비율로 되돌려 마디를 찾는다.
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
01조각마다 시차를 두고 시계 방향으로 쓸며 그려지는 원형 그래프
도넛이 아닌 꽉 찬 원에서 일곱 조각이 각자 자기 시작 각부터 80ms 씩 늦게 자라므로 조각 사이 틈이 열렸다 닫히고, 반지름도 0.9R 에서 R 로 함께 커지며 펴지는 끝 각에는 밝은 선이 따라붙는다. 다 그리는 데 (7 − 1) × 80 + 900 = 1,380ms 가 걸리고, easeOutCubic 이라 각 조각은 자기 시간의 절반이 지났을 때 이미 각도의 87.5% 까지 와 있으며, 회차마다 시드가 1 올라 새 가상 값으로 다시 그린다. 보고서 첫 장처럼 비율을 한 번 펼쳐 보이는 자리에 맞고, 렌더 수치는 누적면적 22.6%·강도 122.5·19/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="가상 값 일곱 개를 조각마다 시차를 두고 시계 방향으로 쓸며 그리는 원형 그래프. ←/→ 로 조각을 고르면 이름과 비율을 읽어 준다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">조각마다 시차를 두고 그리는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="replay" type="button">다시 그리기</button>
<button class="pc__btn" data-role="data" type="button">새 데이터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 조각 하나가 폭 0 에서 제 각도까지 펼쳐지는 시간
const SWEEP_MS = 900;
// i 번째 조각은 i × SLICE_STAGGER 만큼 늦게 출발한다 — 앞 조각이 다 펴지기 전에 다음 조각이 따라 나온다
const SLICE_STAGGER = 80;
// 가상 데이터 시드 — 같은 시드면 같은 값이 다시 나온다 (실제 통계가 아니다)
const DATA_SEED = 467;
const SLICE_N = 7;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_DRAW = '조각마다 시차를 두고 그리는 중';
const TXT_DONE = '다 그림 — ←/→ 로 조각 고르기';
const replayBtn = root.querySelector('[data-role="replay"]');
const dataBtn = root.querySelector('[data-role="data"]');
let seed = DATA_SEED;
let slices = layout(seededValues(seed, SLICE_N, 6, 40));
// -1 이면 출발 전(빈 원판), 0 부터 ms 로 센다
let elapsed = -1;
let pick = -1;
function totalMs() { return (SLICE_N - 1) * SLICE_STAGGER + SWEEP_MS; }
// i 번째 조각의 진행률 0~1 — 늦게 출발한 만큼 빼고 이징을 건다
function progress(i) {
if (elapsed < 0) {
return 0;
}
return easeOutCubic((elapsed - i * SLICE_STAGGER) / SWEEP_MS);
}
function done() { return elapsed >= totalMs(); }
function report() {
if (pick >= 0) {
sayPick(pick, slices[pick].share);
return;
}
say(done() ? TXT_DONE : TXT_DRAW);
}
function setData(s) {
seed = s;
slices = layout(seededValues(seed, SLICE_N, 6, 40));
pick = -1;
}
function step() {
if (elapsed < 0 || done()) {
return;
}
elapsed += FIXED_DT;
}
function settle() {
if (elapsed < totalMs()) {
elapsed = totalMs();
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [0, 200], function (k, cyc) {
if (k === 0) {
if (cyc > 0) {
setData(DATA_SEED + cyc);
}
elapsed = -1;
}
else {
elapsed = 0;
}
report();
});
}
function draw() {
clearStage();
// 빈 원판 자리 — 그리기 전에도 무대가 비어 보이지 않게
ctx.fillStyle = rgba(PANEL, 1);
ctx.beginPath();
ctx.arc(CX, CY, R, 0, TAU);
ctx.fill();
for (let i = 0; i < slices.length; i += 1) {
const s = slices[i];
const e = progress(i);
if (e <= 0) {
continue;
}
const end = s.a0 + (s.a1 - s.a0) * e;
const r = R * (0.9 + 0.1 * e) + (i === pick ? 6 : 0);
const c = pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : PAL[i];
ctx.fillStyle = rgba(c, 1);
wedgePath(ctx, CX, CY, 0, r, s.a0, end);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
// 펼쳐지는 앞날 — 아직 펴지는 조각의 끝 각에 밝은 선
if (e < 1) {
ctx.strokeStyle = rgba(PAPER, 0.9 * (1 - e));
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.lineTo(CX + dirX(end) * r, CY + dirY(end) * r);
ctx.stroke();
}
}
// 비율 글자 — 다 펴진 조각 가운데 각 위, 6% 이상인 조각만
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
for (let i = 0; i < slices.length; i += 1) {
const s = slices[i];
const e = progress(i);
if (e < 1 || s.share < 0.06) {
continue;
}
const mid = (s.a0 + s.a1) / 2;
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(s.share), CX + dirX(mid) * R * 0.64, CY + dirY(mid) * R * 0.64);
}
let total = 0;
let drawn = 0;
for (let i = 0; i < slices.length; i += 1) {
const e = progress(i);
legendRow(i, LABELS[i], e >= 1 ? pctText(slices[i].share) : '', PAL[i], i === pick, e <= 0);
total += slices[i].value;
if (e >= 1) {
drawn += 1;
}
}
statRow(212, '그린 조각', String(drawn) + ' / ' + String(SLICE_N) + ' · ' + groupDigits(total));
}
function after() {
// 시연이 빈 원판에서 멈췄으면 사람 입력과 함께 그리기를 시작한다
if (elapsed < 0) {
elapsed = 0;
}
if (still) {
settle();
}
report();
draw();
}
function replay() {
elapsed = 0;
after();
}
function movePick(d) {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) <= R + 8) {
pick = sliceAt(slices, angleAt(p[0], p[1], CX, CY));
after();
}
else {
replay();
}
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter') {
ev.preventDefault();
wake();
replay();
}
else if (ev.key === ' ') {
ev.preventDefault();
wake();
setData(seed + 1);
replay();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
after();
}
});
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replay(); });
}
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); setData(seed + 1); replay(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieSweepStaggerDraw - vanilla 판(01-pie-chart-sweep-stagger-draw) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieSweepStaggerDraw.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieSweepStaggerDraw() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 조각 하나가 폭 0 에서 제 각도까지 펼쳐지는 시간
const SWEEP_MS = 900;
// i 번째 조각은 i × SLICE_STAGGER 만큼 늦게 출발한다 — 앞 조각이 다 펴지기 전에 다음 조각이 따라 나온다
const SLICE_STAGGER = 80;
// 가상 데이터 시드 — 같은 시드면 같은 값이 다시 나온다 (실제 통계가 아니다)
const DATA_SEED = 467;
const SLICE_N = 7;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_DRAW = '조각마다 시차를 두고 그리는 중';
const TXT_DONE = '다 그림 — ←/→ 로 조각 고르기';
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
let seed = DATA_SEED;
let slices: Slice[] = layout(seededValues(seed, SLICE_N, 6, 40));
// -1 이면 출발 전(빈 원판), 0 부터 ms 로 센다
let elapsed = -1;
let pick = -1;
function totalMs(): number { return (SLICE_N - 1) * SLICE_STAGGER + SWEEP_MS; }
// i 번째 조각의 진행률 0~1 — 늦게 출발한 만큼 빼고 이징을 건다
function progress(i: number): number {
if (elapsed < 0) { return 0; }
return easeOutCubic((elapsed - i * SLICE_STAGGER) / SWEEP_MS);
}
function done(): boolean { return elapsed >= totalMs(); }
function report(): void {
if (pick >= 0) { sayPick(pick, slices[pick].share); return; }
say(done() ? TXT_DONE : TXT_DRAW);
}
function setData(s: number): void {
seed = s;
slices = layout(seededValues(seed, SLICE_N, 6, 40));
pick = -1;
}
function step(): void {
if (elapsed < 0 || done()) { return; }
elapsed += FIXED_DT;
}
function settle(): void {
if (elapsed < totalMs()) { elapsed = totalMs(); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [0, 200], function (k: number, cyc: number): void {
if (k === 0) {
if (cyc > 0) { setData(DATA_SEED + cyc); }
elapsed = -1;
} else {
elapsed = 0;
}
report();
});
}
function draw(): void {
clearStage();
// 빈 원판 자리 — 그리기 전에도 무대가 비어 보이지 않게
ctx.fillStyle = rgba(PANEL, 1);
ctx.beginPath();
ctx.arc(CX, CY, R, 0, TAU);
ctx.fill();
for (let i = 0; i < slices.length; i += 1) {
const s = slices[i];
const e = progress(i);
if (e <= 0) { continue; }
const end = s.a0 + (s.a1 - s.a0) * e;
const r = R * (0.9 + 0.1 * e) + (i === pick ? 6 : 0);
const c = pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : PAL[i];
ctx.fillStyle = rgba(c, 1);
wedgePath(ctx, CX, CY, 0, r, s.a0, end);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
// 펼쳐지는 앞날 — 아직 펴지는 조각의 끝 각에 밝은 선
if (e < 1) {
ctx.strokeStyle = rgba(PAPER, 0.9 * (1 - e));
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.lineTo(CX + dirX(end) * r, CY + dirY(end) * r);
ctx.stroke();
}
}
// 비율 글자 — 다 펴진 조각 가운데 각 위, 6% 이상인 조각만
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
for (let i = 0; i < slices.length; i += 1) {
const s = slices[i];
const e = progress(i);
if (e < 1 || s.share < 0.06) { continue; }
const mid = (s.a0 + s.a1) / 2;
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(s.share), CX + dirX(mid) * R * 0.64, CY + dirY(mid) * R * 0.64);
}
let total = 0;
let drawn = 0;
for (let i = 0; i < slices.length; i += 1) {
const e = progress(i);
legendRow(i, LABELS[i], e >= 1 ? pctText(slices[i].share) : '', PAL[i], i === pick, e <= 0);
total += slices[i].value;
if (e >= 1) { drawn += 1; }
}
statRow(212, '그린 조각', String(drawn) + ' / ' + String(SLICE_N) + ' · ' + groupDigits(total));
}
function after(): void {
// 시연이 빈 원판에서 멈췄으면 사람 입력과 함께 그리기를 시작한다
if (elapsed < 0) { elapsed = 0; }
if (still) { settle(); }
report();
draw();
}
function replay(): void {
elapsed = 0;
after();
}
function movePick(d: number): void {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) <= R + 8) {
pick = sliceAt(slices, angleAt(p[0], p[1], CX, CY));
after();
} else {
replay();
}
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter') { ev.preventDefault(); wake(); replay(); }
else if (ev.key === ' ') { ev.preventDefault(); wake(); setData(seed + 1); replay(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; after(); }
});
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replay(); }); }
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); setData(seed + 1); replay(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="가상 값 일곱 개를 조각마다 시차를 두고 시계 방향으로 쓸며 그리는 원형 그래프. ←/→ 로 조각을 고르면 이름과 비율을 읽어 준다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">조각마다 시차를 두고 그리는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="replay" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="data" type="button">새 데이터</button>
</div>
</div>
);
}01 · 조각마다 시차를 두고 시계 방향으로 쓸며 그려지는 원형 그래프 — 측정 누적면적 22.602% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function progress(i) {
if (elapsed < 0) {
return 0;
}
return easeOutCubic((elapsed - i * SLICE_STAGGER) / SWEEP_MS);
}
02마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오는 원형 그래프
포인터 아래 조각이나 눌러 고정한 조각만 목표 거리 16px 를 받고, 스프링 값 x 하나가 위치와 밝기, 그림자 오프셋에 함께 쓰여 조각이 들려 올라오는 느낌을 만든다. SPRING_K 170·SPRING_DAMP 14 의 감쇠비는 14 ÷ (2√170) = 0.537 이라 272ms 에 17.82px 까지 11.3% 넘쳤다가 400ms 부터 ±0.5px 안에 머물고, 덜 나온 조각부터 칠해 튀어나온 조각이 이웃에 덮이지 않는다. 대시보드에서 조각을 짚어 비율을 확인하게 하는 자리에 맞고, 렌더 수치는 누적면적 25.6%·강도 84.0·23/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="포인터를 올린 조각이 가운데 각 방향으로 스프링처럼 튀어나오는 원형 그래프. ←/→ 로 조각을 고르고 Enter 로 고정한다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">조각에 포인터를 올려 보기</p>
<div class="pc__tools">
<button class="pc__btn" data-role="pin" type="button" aria-pressed="false">고른 조각 고정</button>
<button class="pc__btn" data-role="reset" type="button">모두 넣기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
function springStep(s, k, d, dt) {
const acc = -k * (s.x - s.to) - d * s.v;
s.v += acc * dt;
s.x += s.v * dt;
}
// 고른 조각이 가운데 각 방향(코사인·사인)으로 밀려나는 거리(px)
const EXPLODE_DIST = 16;
// 감쇠 스프링 — 당기는 힘(1/s²)과 끄는 힘(1/s). 감쇠비 = SPRING_DAMP / (2√SPRING_K) 이 1 보다 작아 한 번 살짝 넘쳤다 선다
const SPRING_K = 170;
const SPRING_DAMP = 14;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_IDLE = '조각에 포인터를 올려 보기';
const pinBtn = root.querySelector('[data-role="pin"]');
const resetBtn = root.querySelector('[data-role="reset"]');
const slices = layout(seededValues(SEED, SLICE_N, 8, 36));
const springs = [];
for (let i = 0; i < SLICE_N; i += 1) {
springs.push({ x: 0, v: 0, to: 0 });
}
// 시연은 큰 조각부터 네 개를 차례로 띄운다
const DEMO_PICKS = slices.map(function (_s, i) { return i; })
.sort(function (a, b) { return slices[b].share - slices[a].share; }).slice(0, 4);
let hover = -1;
let pinned = -1;
function focusIdx() { return hover >= 0 ? hover : pinned; }
function retarget() {
for (let i = 0; i < SLICE_N; i += 1) {
springs[i].to = i === hover || i === pinned ? EXPLODE_DIST : 0;
}
}
function report() {
const f = focusIdx();
if (f >= 0) {
sayPick(f, slices[f].share);
}
else {
say(TXT_IDLE);
}
}
function step() {
const dt = FIXED_DT / 1000;
for (let i = 0; i < SLICE_N; i += 1) {
springStep(springs[i], SPRING_K, SPRING_DAMP, dt);
}
}
function settle() {
for (let i = 0; i < SLICE_N; i += 1) {
springs[i].x = springs[i].to;
springs[i].v = 0;
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 600, 1000, 1400, 1800], function (k) {
hover = k < DEMO_PICKS.length ? DEMO_PICKS[k] : -1;
retarget();
report();
});
}
function midOf(i) { return (slices[i].a0 + slices[i].a1) / 2; }
function draw() {
clearStage();
// 덜 나온 조각부터 칠해 튀어나온 조각이 맨 위에 오게 한다
const order = slices.map(function (_s, i) { return i; })
.sort(function (a, b) { return springs[a].x - springs[b].x; });
// 그림자 — 튀어나온 만큼 짙어지고 아래로 멀어진다
for (let n = 0; n < order.length; n += 1) {
const i = order[n];
const x = springs[i].x;
if (x <= 0.3) {
continue;
}
const m = midOf(i);
const k = clamp(x / EXPLODE_DIST, 0, 1.4);
ctx.fillStyle = 'rgba(8,6,16,' + (0.42 * Math.min(k, 1)).toFixed(3) + ')';
wedgePath(ctx, CX + dirX(m) * x + x * 0.3, CY + dirY(m) * x + x * 0.6, 0, R, slices[i].a0, slices[i].a1);
ctx.fill();
}
for (let n = 0; n < order.length; n += 1) {
const i = order[n];
const x = springs[i].x;
const m = midOf(i);
// 밀려난 거리만큼 밝아진다 — 같은 값이 위치·밝기·그림자 셋에 함께 쓰인다
const lift = clamp(x / EXPLODE_DIST, 0, 1.3);
ctx.fillStyle = rgba(mix(PAL[i], PAPER, 0.3 * lift), 1);
wedgePath(ctx, CX + dirX(m) * x, CY + dirY(m) * x, 0, R, slices[i].a0, slices[i].a1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
if (slices[i].share >= 0.07) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = (x > 2 ? '800' : '700') + ' 12px sans-serif';
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(slices[i].share), CX + dirX(m) * (R * 0.64 + x), CY + dirY(m) * (R * 0.64 + x));
}
}
const f = focusIdx();
for (let i = 0; i < SLICE_N; i += 1) {
legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === f, false);
}
statRow(220, '튀어나온 거리', (f >= 0 ? springs[f].x : 0).toFixed(1) + ' px');
}
function after() {
retarget();
if (still) {
settle();
}
report();
draw();
if (pinBtn) {
pinBtn.setAttribute('aria-pressed', pinned >= 0 ? 'true' : 'false');
}
}
// 포인터 자리의 조각 — 각은 atan2, 거리는 그 조각이 지금 밀려난 만큼 넉넉히 본다
function hitSlice(x, y) {
const i = sliceAt(slices, angleAt(x, y, CX, CY));
if (i < 0) {
return -1;
}
return Math.hypot(x - CX, y - CY) <= R + Math.max(0, springs[i].x) + 4 ? i : -1;
}
function togglePin(i) {
pinned = i < 0 || pinned === i ? -1 : i;
}
function movePick(d) {
const f = focusIdx();
hover = f < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (f + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointermove', function (ev) {
wake();
const p = toScene(ev);
const i = hitSlice(p[0], p[1]);
if (i !== hover) {
hover = i;
after();
}
});
cv.addEventListener('pointerleave', function () {
if (demo) {
return;
}
hover = -1;
after();
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
togglePin(hitSlice(p[0], p[1]));
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
togglePin(focusIdx());
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
hover = -1;
pinned = -1;
after();
}
});
if (pinBtn) {
pinBtn.addEventListener('click', function () { wake(); togglePin(focusIdx() >= 0 ? focusIdx() : DEMO_PICKS[0]); after(); });
}
if (resetBtn) {
resetBtn.addEventListener('click', function () { wake(); hover = -1; pinned = -1; after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieSlicePopSpring - vanilla 판(02-pie-chart-slice-pop-spring) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieSlicePopSpring.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieSlicePopSpring() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 감쇠 스프링 — x 가 목표 to 로 당겨지고(k, 1/s²) 속도에 비례해 끌린다(d, 1/s). 속도를 먼저 고치는 반암시 오일러
type Spring = { x: number; v: number; to: number };
function springStep(s: Spring, k: number, d: number, dt: number): void {
const acc = -k * (s.x - s.to) - d * s.v;
s.v += acc * dt;
s.x += s.v * dt;
}
// 고른 조각이 가운데 각 방향(코사인·사인)으로 밀려나는 거리(px)
const EXPLODE_DIST = 16;
// 감쇠 스프링 — 당기는 힘(1/s²)과 끄는 힘(1/s). 감쇠비 = SPRING_DAMP / (2√SPRING_K) 이 1 보다 작아 한 번 살짝 넘쳤다 선다
const SPRING_K = 170;
const SPRING_DAMP = 14;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_IDLE = '조각에 포인터를 올려 보기';
const pinBtn = root.querySelector('[data-role="pin"]') as HTMLButtonElement | null;
const resetBtn = root.querySelector('[data-role="reset"]') as HTMLButtonElement | null;
const slices: Slice[] = layout(seededValues(SEED, SLICE_N, 8, 36));
const springs: Spring[] = [];
for (let i = 0; i < SLICE_N; i += 1) { springs.push({ x: 0, v: 0, to: 0 }); }
// 시연은 큰 조각부터 네 개를 차례로 띄운다
const DEMO_PICKS: number[] = slices.map(function (_s: Slice, i: number): number { return i; })
.sort(function (a: number, b: number): number { return slices[b].share - slices[a].share; }).slice(0, 4);
let hover = -1;
let pinned = -1;
function focusIdx(): number { return hover >= 0 ? hover : pinned; }
function retarget(): void {
for (let i = 0; i < SLICE_N; i += 1) { springs[i].to = i === hover || i === pinned ? EXPLODE_DIST : 0; }
}
function report(): void {
const f = focusIdx();
if (f >= 0) { sayPick(f, slices[f].share); } else { say(TXT_IDLE); }
}
function step(): void {
const dt = FIXED_DT / 1000;
for (let i = 0; i < SLICE_N; i += 1) { springStep(springs[i], SPRING_K, SPRING_DAMP, dt); }
}
function settle(): void {
for (let i = 0; i < SLICE_N; i += 1) { springs[i].x = springs[i].to; springs[i].v = 0; }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 600, 1000, 1400, 1800], function (k: number): void {
hover = k < DEMO_PICKS.length ? DEMO_PICKS[k] : -1;
retarget();
report();
});
}
function midOf(i: number): number { return (slices[i].a0 + slices[i].a1) / 2; }
function draw(): void {
clearStage();
// 덜 나온 조각부터 칠해 튀어나온 조각이 맨 위에 오게 한다
const order: number[] = slices.map(function (_s: Slice, i: number): number { return i; })
.sort(function (a: number, b: number): number { return springs[a].x - springs[b].x; });
// 그림자 — 튀어나온 만큼 짙어지고 아래로 멀어진다
for (let n = 0; n < order.length; n += 1) {
const i = order[n];
const x = springs[i].x;
if (x <= 0.3) { continue; }
const m = midOf(i);
const k = clamp(x / EXPLODE_DIST, 0, 1.4);
ctx.fillStyle = 'rgba(8,6,16,' + (0.42 * Math.min(k, 1)).toFixed(3) + ')';
wedgePath(ctx, CX + dirX(m) * x + x * 0.3, CY + dirY(m) * x + x * 0.6, 0, R, slices[i].a0, slices[i].a1);
ctx.fill();
}
for (let n = 0; n < order.length; n += 1) {
const i = order[n];
const x = springs[i].x;
const m = midOf(i);
// 밀려난 거리만큼 밝아진다 — 같은 값이 위치·밝기·그림자 셋에 함께 쓰인다
const lift = clamp(x / EXPLODE_DIST, 0, 1.3);
ctx.fillStyle = rgba(mix(PAL[i], PAPER, 0.3 * lift), 1);
wedgePath(ctx, CX + dirX(m) * x, CY + dirY(m) * x, 0, R, slices[i].a0, slices[i].a1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
if (slices[i].share >= 0.07) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = (x > 2 ? '800' : '700') + ' 12px sans-serif';
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(slices[i].share), CX + dirX(m) * (R * 0.64 + x), CY + dirY(m) * (R * 0.64 + x));
}
}
const f = focusIdx();
for (let i = 0; i < SLICE_N; i += 1) { legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === f, false); }
statRow(220, '튀어나온 거리', (f >= 0 ? springs[f].x : 0).toFixed(1) + ' px');
}
function after(): void {
retarget();
if (still) { settle(); }
report();
draw();
if (pinBtn) { pinBtn.setAttribute('aria-pressed', pinned >= 0 ? 'true' : 'false'); }
}
// 포인터 자리의 조각 — 각은 atan2, 거리는 그 조각이 지금 밀려난 만큼 넉넉히 본다
function hitSlice(x: number, y: number): number {
const i = sliceAt(slices, angleAt(x, y, CX, CY));
if (i < 0) { return -1; }
return Math.hypot(x - CX, y - CY) <= R + Math.max(0, springs[i].x) + 4 ? i : -1;
}
function togglePin(i: number): void {
pinned = i < 0 || pinned === i ? -1 : i;
}
function movePick(d: number): void {
const f = focusIdx();
hover = f < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (f + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hitSlice(p[0], p[1]);
if (i !== hover) { hover = i; after(); }
});
cv.addEventListener('pointerleave', function (): void {
if (demo) { return; }
hover = -1;
after();
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
togglePin(hitSlice(p[0], p[1]));
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); wake(); togglePin(focusIdx()); after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); hover = -1; pinned = -1; after(); }
});
if (pinBtn) { pinBtn.addEventListener('click', function (): void { wake(); togglePin(focusIdx() >= 0 ? focusIdx() : DEMO_PICKS[0]); after(); }); }
if (resetBtn) { resetBtn.addEventListener('click', function (): void { wake(); hover = -1; pinned = -1; after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="포인터를 올린 조각이 가운데 각 방향으로 스프링처럼 튀어나오는 원형 그래프. ←/→ 로 조각을 고르고 Enter 로 고정한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">조각에 포인터를 올려 보기</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="pin" type="button" aria-pressed="false">고른 조각 고정</button>
<button className={cx("pc__btn")} data-role="reset" type="button">모두 넣기</button>
</div>
</div>
);
}02 · 마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오는 원형 그래프 — 측정 누적면적 25.572% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function springStep(s, k, d, dt) {
const acc = -k * (s.x - s.to) - d * s.v;
s.v += acc * dt;
s.x += s.v * dt;
}
// 고른 조각이 가운데 각 방향(코사인·사인)으로 밀려나는 거리(px)
const EXPLODE_DIST = 16;
03원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트
원판을 55° 눕혀 세로를 cos 55° = 0.574 배로 줄인 타원에 투영하고, 두께 26px 는 sin 을 곱해 화면에 21.3px 높이의 옆면으로 보인다. 옆면은 4° 띠로 나눠 띠마다 법선과 왼쪽 앞에서 오는 빛의 내적으로 밝기를 0.55~1 사이에서 정하고, 가운데 각이 화면 뒤쪽인 조각부터 칠하는 화가 알고리즘으로 앞 조각이 뒤 조각을 덮게 한다. 원형 차트 3 차원 효과가 필요한 발표 표지 그래프에 맞고, 포인터 높이로 기울기가 20~75° 사이를 오가며 렌더 수치는 누적면적 19.7%·강도 85.1·23/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트. 포인터 높이나 ↑/↓ 로 기울기를 바꾸고 ←/→ 로 조각을 고른다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">포인터 높이로 원판을 기울이는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="top" type="button">위에서 보기</button>
<button class="pc__btn" data-role="side" type="button">옆에서 보기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 원판을 앞으로 눕히는 각(도) — 원 위의 점을 세로로 cos(기울기) 만큼 눌러 타원으로 투영한다
const TILT_DEG = 55;
// 원판 두께(px) — 화면에서는 sin(기울기) 를 곱한 만큼만 보인다 (위에서 보면 0, 옆에서 보면 전부)
const DEPTH_PX = 26;
// 옆면이 빛을 등질수록 어두워지는 몫 — 0 이면 옆면 밝기가 한결같다
const SIDE_SHADE = 0.45;
const GAP_PX = 5;
const LIFT_PX = 10;
const TILT_MIN = 20;
const TILT_MAX = 75;
// 시연 중에만 원판을 돌린다(rad/s)
const SPIN_SPEED = 0.8;
const SEG = (4 * Math.PI) / 180;
const DEG = Math.PI / 180;
// 빛은 왼쪽 앞에서 — 바깥 법선이 이쪽을 볼수록 옆면이 밝다
const LIGHT_X = -0.55;
const LIGHT_Y = 0.83;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 148;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_IDLE = '포인터 높이로 원판을 기울이는 중';
const topBtn = root.querySelector('[data-role="top"]');
const sideBtn = root.querySelector('[data-role="side"]');
const slices = layout(seededValues(SEED, SLICE_N, 10, 36));
let tilt = TILT_DEG * DEG;
let tiltTo = tilt;
let rot = 0;
let rotTo = 0;
let pick = -1;
const lifts = slices.map(function () { return 0; });
function report() {
if (pick >= 0) {
sayPick(pick, slices[pick].share);
}
else {
say(TXT_IDLE);
}
}
function follow(cur, to, rate) {
return cur + (to - cur) * (1 - Math.exp(-rate * FIXED_DT / 1000));
}
function step() {
tilt = follow(tilt, tiltTo, 9);
rot = follow(rot, rotTo, 9);
for (let i = 0; i < SLICE_N; i += 1) {
lifts[i] = follow(lifts[i], i === pick ? LIFT_PX : 0, 10);
}
}
function settle() {
tilt = tiltTo;
rot = rotTo;
for (let i = 0; i < SLICE_N; i += 1) {
lifts[i] = i === pick ? LIFT_PX : 0;
}
}
function demoTick(t) {
if (t < 200) {
return;
}
const p = t - 200;
tiltTo = (TILT_DEG + 15 * Math.sin((TAU * p) / DEMO_LOOP)) * DEG;
rotTo = (SPIN_SPEED * p) / 1000;
}
// 판 위의 점(px, py, 높이 z) → 화면. 세로는 cos(기울기) 로 눌리고 높이는 sin(기울기) 만큼 위로 선다
function proj(px, py, z) {
return [CX + px, CY + py * Math.cos(tilt) + (DEPTH_PX / 2 - z) * Math.sin(tilt)];
}
function quad(p, c) {
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1]);
for (let k = 1; k < p.length; k += 1) {
ctx.lineTo(p[k][0], p[k][1]);
}
ctx.closePath();
ctx.fillStyle = rgba(c, 1);
ctx.fill();
// 이웃 띠 사이 머리카락 틈을 같은 색 선으로 메운다
ctx.strokeStyle = rgba(c, 1);
ctx.lineWidth = 0.8;
ctx.stroke();
}
// 옆면 밝기 — 판 위 법선(nx, ny)과 빛 방향의 내적, 등질수록 SIDE_SHADE 만큼 어두워진다
function sideColor(i, nx, ny) {
const f = 1 - SIDE_SHADE * (1 - Math.max(0, nx * LIGHT_X + ny * LIGHT_Y));
const base = mix(PAL[i], BG, 0.2);
return [base[0] * f, base[1] * f, base[2] * f];
}
function drawSlice(i) {
const s = slices[i];
const m = (s.a0 + s.a1) / 2 + rot;
const ox = Math.cos(TOP + m) * GAP_PX;
const oy = Math.sin(TOP + m) * GAP_PX;
const zb = lifts[i];
const zt = DEPTH_PX + lifts[i];
const f0 = TOP + s.a0 + rot;
const f1 = TOP + s.a1 + rot;
// 자른 면 두 장 — 바깥 법선이 보는 사람 쪽(+y)일 때만 보인다
const walls = [[f0, Math.sin(f0), -Math.cos(f0)], [f1, -Math.sin(f1), Math.cos(f1)]];
for (let w = 0; w < 2; w += 1) {
const f = walls[w][0];
const nx = walls[w][1];
const ny = walls[w][2];
if (ny <= 0) {
continue;
}
const ex = ox + Math.cos(f) * R;
const ey = oy + Math.sin(f) * R;
quad([proj(ox, oy, zt), proj(ex, ey, zt), proj(ex, ey, zb), proj(ox, oy, zb)], sideColor(i, nx, ny));
}
// 바깥 옆면 — 4° 띠마다 법선을 따로 재서 각도별로 밝기가 바뀐다
const n = Math.max(1, Math.ceil((f1 - f0) / SEG));
for (let k = 0; k < n; k += 1) {
const g0 = f0 + ((f1 - f0) * k) / n;
const g1 = f0 + ((f1 - f0) * (k + 1)) / n;
const gm = (g0 + g1) / 2;
if (Math.sin(gm) <= 0) {
continue;
}
const x0 = ox + Math.cos(g0) * R;
const y0 = oy + Math.sin(g0) * R;
const x1 = ox + Math.cos(g1) * R;
const y1 = oy + Math.sin(g1) * R;
quad([proj(x0, y0, zt), proj(x1, y1, zt), proj(x1, y1, zb), proj(x0, y0, zb)], sideColor(i, Math.cos(gm), Math.sin(gm)));
}
// 윗면 — 타원 위 부채꼴
ctx.beginPath();
const c0 = proj(ox, oy, zt);
ctx.moveTo(c0[0], c0[1]);
for (let k = 0; k <= n; k += 1) {
const g = f0 + ((f1 - f0) * k) / n;
const q = proj(ox + Math.cos(g) * R, oy + Math.sin(g) * R, zt);
ctx.lineTo(q[0], q[1]);
}
ctx.closePath();
ctx.fillStyle = rgba(i === pick ? mix(PAL[i], PAPER, 0.25) : PAL[i], 1);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.28);
ctx.lineWidth = 1;
ctx.stroke();
if (s.share >= 0.07) {
const t = proj(ox + Math.cos(m + TOP) * R * 0.62, oy + Math.sin(m + TOP) * R * 0.62, zt);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(s.share), t[0], t[1]);
}
}
function draw() {
clearStage();
// 바닥 그림자 — 원판 아래 타원
const g = proj(0, 0, 0);
ctx.fillStyle = 'rgba(8,6,16,0.35)';
ctx.beginPath();
ctx.ellipse(g[0] + 6, g[1] + 8, R + 10, (R + 10) * Math.cos(tilt) + 2, 0, 0, TAU);
ctx.fill();
// 화가 알고리즘 — 가운데 각이 화면 뒤(작은 y)인 조각부터 칠해 앞 조각이 뒤 조각의 옆면을 덮게 한다
const order = slices.map(function (_s, i) { return i; }).sort(function (a, b) {
const ma = (slices[a].a0 + slices[a].a1) / 2 + rot;
const mb = (slices[b].a0 + slices[b].a1) / 2 + rot;
return Math.sin(TOP + ma) - Math.sin(TOP + mb);
});
for (let k = 0; k < order.length; k += 1) {
drawSlice(order[k]);
}
for (let i = 0; i < SLICE_N; i += 1) {
legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === pick, false);
}
statRow(212, '기울기 · 세로 비율', (tilt / DEG).toFixed(0) + '° · ' + Math.cos(tilt).toFixed(2));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
// 화면 → 판 위 각 — 윗면 높이에서 투영을 거꾸로 풀고 atan2 로 각을 잰다
function hitSlice(x, y) {
const py = (y - CY + (DEPTH_PX / 2) * Math.sin(tilt)) / Math.cos(tilt);
const px = x - CX;
if (Math.hypot(px, py) > R + GAP_PX) {
return -1;
}
let a = angleAt(px, py, 0, 0) - rot;
a = ((a % TAU) + TAU) % TAU;
return sliceAt(slices, a);
}
function setTilt(deg) { tiltTo = clamp(deg, TILT_MIN, TILT_MAX) * DEG; }
function movePick(d) {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointermove', function (ev) {
wake();
const p = toScene(ev);
setTilt(lerp(TILT_MIN, TILT_MAX, clamp((p[1] - 20) / 260, 0, 1)));
after();
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hitSlice(p[0], p[1]);
pick = i === pick ? -1 : i;
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'ArrowUp') {
ev.preventDefault();
wake();
setTilt(tiltTo / DEG - 5);
after();
}
else if (ev.key === 'ArrowDown') {
ev.preventDefault();
wake();
setTilt(tiltTo / DEG + 5);
after();
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
pick = pick < 0 ? 0 : -1;
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
after();
}
});
if (topBtn) {
topBtn.addEventListener('click', function () { wake(); setTilt(TILT_MIN); after(); });
}
if (sideBtn) {
sideBtn.addEventListener('click', function () { wake(); setTilt(TILT_MAX); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieTiltSideWall3d - vanilla 판(03-pie-chart-3d-tilt-side-wall) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieTiltSideWall3d.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieTiltSideWall3d() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 원판을 앞으로 눕히는 각(도) — 원 위의 점을 세로로 cos(기울기) 만큼 눌러 타원으로 투영한다
const TILT_DEG = 55;
// 원판 두께(px) — 화면에서는 sin(기울기) 를 곱한 만큼만 보인다 (위에서 보면 0, 옆에서 보면 전부)
const DEPTH_PX = 26;
// 옆면이 빛을 등질수록 어두워지는 몫 — 0 이면 옆면 밝기가 한결같다
const SIDE_SHADE = 0.45;
const GAP_PX = 5;
const LIFT_PX = 10;
const TILT_MIN = 20;
const TILT_MAX = 75;
// 시연 중에만 원판을 돌린다(rad/s)
const SPIN_SPEED = 0.8;
const SEG = (4 * Math.PI) / 180;
const DEG = Math.PI / 180;
// 빛은 왼쪽 앞에서 — 바깥 법선이 이쪽을 볼수록 옆면이 밝다
const LIGHT_X = -0.55;
const LIGHT_Y = 0.83;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 148;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_IDLE = '포인터 높이로 원판을 기울이는 중';
const topBtn = root.querySelector('[data-role="top"]') as HTMLButtonElement | null;
const sideBtn = root.querySelector('[data-role="side"]') as HTMLButtonElement | null;
const slices: Slice[] = layout(seededValues(SEED, SLICE_N, 10, 36));
let tilt = TILT_DEG * DEG;
let tiltTo = tilt;
let rot = 0;
let rotTo = 0;
let pick = -1;
const lifts: number[] = slices.map(function (): number { return 0; });
function report(): void {
if (pick >= 0) { sayPick(pick, slices[pick].share); } else { say(TXT_IDLE); }
}
function follow(cur: number, to: number, rate: number): number {
return cur + (to - cur) * (1 - Math.exp(-rate * FIXED_DT / 1000));
}
function step(): void {
tilt = follow(tilt, tiltTo, 9);
rot = follow(rot, rotTo, 9);
for (let i = 0; i < SLICE_N; i += 1) { lifts[i] = follow(lifts[i], i === pick ? LIFT_PX : 0, 10); }
}
function settle(): void {
tilt = tiltTo;
rot = rotTo;
for (let i = 0; i < SLICE_N; i += 1) { lifts[i] = i === pick ? LIFT_PX : 0; }
}
function demoTick(t: number): void {
if (t < 200) { return; }
const p = t - 200;
tiltTo = (TILT_DEG + 15 * Math.sin((TAU * p) / DEMO_LOOP)) * DEG;
rotTo = (SPIN_SPEED * p) / 1000;
}
// 판 위의 점(px, py, 높이 z) → 화면. 세로는 cos(기울기) 로 눌리고 높이는 sin(기울기) 만큼 위로 선다
function proj(px: number, py: number, z: number): [number, number] {
return [CX + px, CY + py * Math.cos(tilt) + (DEPTH_PX / 2 - z) * Math.sin(tilt)];
}
function quad(p: [number, number][], c: RGB): void {
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1]);
for (let k = 1; k < p.length; k += 1) { ctx.lineTo(p[k][0], p[k][1]); }
ctx.closePath();
ctx.fillStyle = rgba(c, 1);
ctx.fill();
// 이웃 띠 사이 머리카락 틈을 같은 색 선으로 메운다
ctx.strokeStyle = rgba(c, 1);
ctx.lineWidth = 0.8;
ctx.stroke();
}
// 옆면 밝기 — 판 위 법선(nx, ny)과 빛 방향의 내적, 등질수록 SIDE_SHADE 만큼 어두워진다
function sideColor(i: number, nx: number, ny: number): RGB {
const f = 1 - SIDE_SHADE * (1 - Math.max(0, nx * LIGHT_X + ny * LIGHT_Y));
const base = mix(PAL[i], BG, 0.2);
return [base[0] * f, base[1] * f, base[2] * f];
}
function drawSlice(i: number): void {
const s = slices[i];
const m = (s.a0 + s.a1) / 2 + rot;
const ox = Math.cos(TOP + m) * GAP_PX;
const oy = Math.sin(TOP + m) * GAP_PX;
const zb = lifts[i];
const zt = DEPTH_PX + lifts[i];
const f0 = TOP + s.a0 + rot;
const f1 = TOP + s.a1 + rot;
// 자른 면 두 장 — 바깥 법선이 보는 사람 쪽(+y)일 때만 보인다
const walls: [number, number, number][] = [[f0, Math.sin(f0), -Math.cos(f0)], [f1, -Math.sin(f1), Math.cos(f1)]];
for (let w = 0; w < 2; w += 1) {
const f = walls[w][0];
const nx = walls[w][1];
const ny = walls[w][2];
if (ny <= 0) { continue; }
const ex = ox + Math.cos(f) * R;
const ey = oy + Math.sin(f) * R;
quad([proj(ox, oy, zt), proj(ex, ey, zt), proj(ex, ey, zb), proj(ox, oy, zb)], sideColor(i, nx, ny));
}
// 바깥 옆면 — 4° 띠마다 법선을 따로 재서 각도별로 밝기가 바뀐다
const n = Math.max(1, Math.ceil((f1 - f0) / SEG));
for (let k = 0; k < n; k += 1) {
const g0 = f0 + ((f1 - f0) * k) / n;
const g1 = f0 + ((f1 - f0) * (k + 1)) / n;
const gm = (g0 + g1) / 2;
if (Math.sin(gm) <= 0) { continue; }
const x0 = ox + Math.cos(g0) * R;
const y0 = oy + Math.sin(g0) * R;
const x1 = ox + Math.cos(g1) * R;
const y1 = oy + Math.sin(g1) * R;
quad([proj(x0, y0, zt), proj(x1, y1, zt), proj(x1, y1, zb), proj(x0, y0, zb)], sideColor(i, Math.cos(gm), Math.sin(gm)));
}
// 윗면 — 타원 위 부채꼴
ctx.beginPath();
const c0 = proj(ox, oy, zt);
ctx.moveTo(c0[0], c0[1]);
for (let k = 0; k <= n; k += 1) {
const g = f0 + ((f1 - f0) * k) / n;
const q = proj(ox + Math.cos(g) * R, oy + Math.sin(g) * R, zt);
ctx.lineTo(q[0], q[1]);
}
ctx.closePath();
ctx.fillStyle = rgba(i === pick ? mix(PAL[i], PAPER, 0.25) : PAL[i], 1);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.28);
ctx.lineWidth = 1;
ctx.stroke();
if (s.share >= 0.07) {
const t = proj(ox + Math.cos(m + TOP) * R * 0.62, oy + Math.sin(m + TOP) * R * 0.62, zt);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(s.share), t[0], t[1]);
}
}
function draw(): void {
clearStage();
// 바닥 그림자 — 원판 아래 타원
const g = proj(0, 0, 0);
ctx.fillStyle = 'rgba(8,6,16,0.35)';
ctx.beginPath();
ctx.ellipse(g[0] + 6, g[1] + 8, R + 10, (R + 10) * Math.cos(tilt) + 2, 0, 0, TAU);
ctx.fill();
// 화가 알고리즘 — 가운데 각이 화면 뒤(작은 y)인 조각부터 칠해 앞 조각이 뒤 조각의 옆면을 덮게 한다
const order: number[] = slices.map(function (_s: Slice, i: number): number { return i; }).sort(function (a: number, b: number): number {
const ma = (slices[a].a0 + slices[a].a1) / 2 + rot;
const mb = (slices[b].a0 + slices[b].a1) / 2 + rot;
return Math.sin(TOP + ma) - Math.sin(TOP + mb);
});
for (let k = 0; k < order.length; k += 1) { drawSlice(order[k]); }
for (let i = 0; i < SLICE_N; i += 1) { legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === pick, false); }
statRow(212, '기울기 · 세로 비율', (tilt / DEG).toFixed(0) + '° · ' + Math.cos(tilt).toFixed(2));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
// 화면 → 판 위 각 — 윗면 높이에서 투영을 거꾸로 풀고 atan2 로 각을 잰다
function hitSlice(x: number, y: number): number {
const py = (y - CY + (DEPTH_PX / 2) * Math.sin(tilt)) / Math.cos(tilt);
const px = x - CX;
if (Math.hypot(px, py) > R + GAP_PX) { return -1; }
let a = angleAt(px, py, 0, 0) - rot;
a = ((a % TAU) + TAU) % TAU;
return sliceAt(slices, a);
}
function setTilt(deg: number): void { tiltTo = clamp(deg, TILT_MIN, TILT_MAX) * DEG; }
function movePick(d: number): void {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
setTilt(lerp(TILT_MIN, TILT_MAX, clamp((p[1] - 20) / 260, 0, 1)));
after();
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hitSlice(p[0], p[1]);
pick = i === pick ? -1 : i;
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'ArrowUp') { ev.preventDefault(); wake(); setTilt(tiltTo / DEG - 5); after(); }
else if (ev.key === 'ArrowDown') { ev.preventDefault(); wake(); setTilt(tiltTo / DEG + 5); after(); }
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); wake(); pick = pick < 0 ? 0 : -1; after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; after(); }
});
if (topBtn) { topBtn.addEventListener('click', function (): void { wake(); setTilt(TILT_MIN); after(); }); }
if (sideBtn) { sideBtn.addEventListener('click', function (): void { wake(); setTilt(TILT_MAX); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트. 포인터 높이나 ↑/↓ 로 기울기를 바꾸고 ←/→ 로 조각을 고른다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">포인터 높이로 원판을 기울이는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="top" type="button">위에서 보기</button>
<button className={cx("pc__btn")} data-role="side" type="button">옆에서 보기</button>
</div>
</div>
);
}03 · 원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트 — 측정 누적면적 19.733% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function proj(px, py, z) {
return [CX + px, CY + py * Math.cos(tilt) + (DEPTH_PX / 2 - z) * Math.sin(tilt)];
}
function quad(p, c) {
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1]);
for (let k = 1; k < p.length; k += 1) {
ctx.lineTo(p[k][0], p[k][1]);
}
ctx.closePath();
ctx.fillStyle = rgba(c, 1);
ctx.fill();
// 이웃 띠 사이 머리카락 틈을 같은 색 선으로 메운다
ctx.strokeStyle = rgba(c, 1);
ctx.lineWidth = 0.8;
ctx.stroke();
}
// 옆면 밝기 — 판 위 법선(nx, ny)과 빛 방향의 내적, 등질수록 SIDE_SHADE 만큼 어두워진다
function sideColor(i, nx, ny) {
const f = 1 - SIDE_SHADE * (1 - Math.max(0, nx * LIGHT_X + ny * LIGHT_Y));
const base = mix(PAL[i], BG, 0.2);
return [base[0] * f, base[1] * f, base[2] * f];
}
04기간을 바꾸면 조각 각도가 이어지며 새 비율로 바뀌는 원형 그래프
기간 셋에는 일곱 항목 가운데 다섯 개씩만 값이 있어서, 넘길 때마다 두 항목은 폭 0 에서 자라 들어오고 두 항목은 0 으로 줄어 빠진다. 항목 순서를 고정한 anglesOf 의 각을 apply 가 시작 각과 끝 각 따로 easeInOutPow(t, 3) 으로 700ms 동안 보간하므로, 이웃이 경계를 함께 써서 전환 한가운데에서도 합계는 정확히 360° 이고 경계 어긋남이 0 이다. 월별·분기별 구성비 카드에 쓰기 좋고, 렌더 수치는 누적면적 23.7%·강도 84.4·20/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="기간을 바꾸면 같은 항목끼리 시작 각과 끝 각을 이어 새 비율로 바뀌는 원형 그래프. Enter 나 1·2·3 으로 기간을 바꾸고 ←/→ 로 조각을 고른다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">같은 항목끼리 각도를 이어 바꾸는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="p0" type="button" aria-pressed="true">기간 1</button>
<button class="pc__btn" data-role="p1" type="button" aria-pressed="false">기간 2</button>
<button class="pc__btn" data-role="p2" type="button" aria-pressed="false">기간 3</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 한 기간에서 다음 기간으로 각도를 옮기는 시간
const MORPH_MS = 700;
// 기간 수 — 기간마다 가상 값 한 벌을 시드로 만든다
const DATA_SETS = 3;
// 거듭제곱 이징의 지수 — 1 이면 등속, 클수록 처음과 끝이 느리고 가운데가 빠르다
const EASE_POW = 3;
const ITEM_N = 7;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const PERIODS = ['기간 1', '기간 2', '기간 3'];
const TXT_MORPH = '같은 항목끼리 각도를 이어 바꾸는 중';
const periodBtns = [0, 1, 2].map(function (k) {
return root.querySelector('[data-role="p' + k + '"]');
});
// 기간마다 들어가는 항목 — (항목 번호 + 기간 × 2) % 7 < 5 인 다섯 개. 기간을 넘길 때마다 두 개가 빠지고 두 개가 새로 들어온다
function buildSets() {
const rnd = makeRng(SEED);
const sets = [];
for (let k = 0; k < DATA_SETS; k += 1) {
const row = [];
for (let j = 0; j < ITEM_N; j += 1) {
const v = 8 + Math.floor(rnd() * 34);
row.push((j + k * 2) % ITEM_N < 5 ? v : 0);
}
sets.push(row);
}
return sets;
}
const SETS = buildSets();
// 항목 순서를 고정하고 누적한다 — 없는 항목(값 0)은 폭 0 으로 그 자리에 남아 짝을 잃지 않는다
function anglesOf(values) {
let total = 0;
for (let j = 0; j < ITEM_N; j += 1) {
total += values[j];
}
const a0 = [];
const a1 = [];
let acc = 0;
for (let j = 0; j < ITEM_N; j += 1) {
a0.push((acc / total) * TAU);
acc += values[j];
a1.push((acc / total) * TAU);
}
return [a0, a1];
}
let period = 0;
const start0 = anglesOf(SETS[0]);
let fromA0 = start0[0].slice();
let fromA1 = start0[1].slice();
let toA0 = start0[0].slice();
let toA1 = start0[1].slice();
const curA0 = start0[0].slice();
const curA1 = start0[1].slice();
let morphMs = MORPH_MS;
let pick = -1;
function width(j) { return curA1[j] - curA0[j]; }
// 시작 각과 끝 각을 따로 보간한다 — 이웃 조각이 경계를 함께 쓰므로 사이가 벌어지지 않는다
function apply() {
const e = easeInOutPow(morphMs / MORPH_MS, EASE_POW);
for (let j = 0; j < ITEM_N; j += 1) {
curA0[j] = lerp(fromA0[j], toA0[j], e);
curA1[j] = lerp(fromA1[j], toA1[j], e);
}
}
function report() {
if (pick >= 0 && width(pick) > 0.001) {
sayPick(pick, width(pick) / TAU);
return;
}
say(PERIODS[period] + ' — ' + TXT_MORPH);
}
function goPeriod(k) {
// 지금 보이는 각에서 출발한다 — 바꾸는 도중에 눌러도 튀지 않는다
fromA0 = curA0.slice();
fromA1 = curA1.slice();
const t = anglesOf(SETS[k]);
toA0 = t[0];
toA1 = t[1];
period = k;
morphMs = 0;
if (pick >= 0 && SETS[k][pick] === 0) {
pick = -1;
}
for (let b = 0; b < periodBtns.length; b += 1) {
const btn = periodBtns[b];
if (btn) {
btn.setAttribute('aria-pressed', b === k ? 'true' : 'false');
}
}
}
function step() {
if (morphMs >= MORPH_MS) {
return;
}
morphMs = Math.min(MORPH_MS, morphMs + FIXED_DT);
apply();
}
function settle() {
morphMs = MORPH_MS;
apply();
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 1000], function () {
goPeriod((period + 1) % DATA_SETS);
report();
});
}
function draw() {
clearStage();
ctx.fillStyle = rgba(PANEL, 1);
ctx.beginPath();
ctx.arc(CX, CY, R, 0, TAU);
ctx.fill();
for (let j = 0; j < ITEM_N; j += 1) {
if (width(j) < 0.0005) {
continue;
}
const r = R + (j === pick ? 6 : 0);
ctx.fillStyle = rgba(pick >= 0 && j !== pick ? mix(PAL[j], BG, 0.38) : PAL[j], 1);
wedgePath(ctx, CX, CY, 0, r, curA0[j], curA1[j]);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
for (let j = 0; j < ITEM_N; j += 1) {
if (width(j) < 0.4) {
continue;
}
const mid = (curA0[j] + curA1[j]) / 2;
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(width(j) / TAU), CX + dirX(mid) * R * 0.64, CY + dirY(mid) * R * 0.64);
}
for (let j = 0; j < ITEM_N; j += 1) {
const w = width(j);
legendRow(j, LABELS[j], w > 0.001 ? pctText(w / TAU) : '-', PAL[j], j === pick, w <= 0.001);
}
statRow(212, '기간', PERIODS[period]);
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function movePick(d) {
let j = pick;
for (let n = 0; n < ITEM_N; n += 1) {
j = j < 0 ? (d > 0 ? 0 : ITEM_N - 1) : (j + d + ITEM_N) % ITEM_N;
if (SETS[period][j] > 0) {
break;
}
}
pick = j;
after();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) <= R + 8) {
const a = angleAt(p[0], p[1], CX, CY);
pick = -1;
for (let j = 0; j < ITEM_N; j += 1) {
if (a >= curA0[j] && a < curA1[j]) {
pick = j;
}
}
}
else {
goPeriod((period + 1) % DATA_SETS);
}
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
goPeriod((period + 1) % DATA_SETS);
after();
}
else if (ev.key === '1' || ev.key === '2' || ev.key === '3') {
ev.preventDefault();
wake();
goPeriod(Number(ev.key) - 1);
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
after();
}
});
for (let b = 0; b < periodBtns.length; b += 1) {
const btn = periodBtns[b];
if (btn) {
btn.addEventListener('click', function () { wake(); goPeriod(b); after(); });
}
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieDataMorphAngleLerp - vanilla 판(04-pie-chart-data-morph-angle-lerp) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieDataMorphAngleLerp.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieDataMorphAngleLerp() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 한 기간에서 다음 기간으로 각도를 옮기는 시간
const MORPH_MS = 700;
// 기간 수 — 기간마다 가상 값 한 벌을 시드로 만든다
const DATA_SETS = 3;
// 거듭제곱 이징의 지수 — 1 이면 등속, 클수록 처음과 끝이 느리고 가운데가 빠르다
const EASE_POW = 3;
const ITEM_N = 7;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const PERIODS = ['기간 1', '기간 2', '기간 3'];
const TXT_MORPH = '같은 항목끼리 각도를 이어 바꾸는 중';
const periodBtns: (HTMLButtonElement | null)[] = [0, 1, 2].map(function (k: number): HTMLButtonElement | null {
return root.querySelector('[data-role="p' + k + '"]') as HTMLButtonElement | null;
});
// 기간마다 들어가는 항목 — (항목 번호 + 기간 × 2) % 7 < 5 인 다섯 개. 기간을 넘길 때마다 두 개가 빠지고 두 개가 새로 들어온다
function buildSets(): number[][] {
const rnd = makeRng(SEED);
const sets: number[][] = [];
for (let k = 0; k < DATA_SETS; k += 1) {
const row: number[] = [];
for (let j = 0; j < ITEM_N; j += 1) {
const v = 8 + Math.floor(rnd() * 34);
row.push((j + k * 2) % ITEM_N < 5 ? v : 0);
}
sets.push(row);
}
return sets;
}
const SETS = buildSets();
// 항목 순서를 고정하고 누적한다 — 없는 항목(값 0)은 폭 0 으로 그 자리에 남아 짝을 잃지 않는다
function anglesOf(values: number[]): number[][] {
let total = 0;
for (let j = 0; j < ITEM_N; j += 1) { total += values[j]; }
const a0: number[] = [];
const a1: number[] = [];
let acc = 0;
for (let j = 0; j < ITEM_N; j += 1) {
a0.push((acc / total) * TAU);
acc += values[j];
a1.push((acc / total) * TAU);
}
return [a0, a1];
}
let period = 0;
const start0 = anglesOf(SETS[0]);
let fromA0: number[] = start0[0].slice();
let fromA1: number[] = start0[1].slice();
let toA0: number[] = start0[0].slice();
let toA1: number[] = start0[1].slice();
const curA0: number[] = start0[0].slice();
const curA1: number[] = start0[1].slice();
let morphMs = MORPH_MS;
let pick = -1;
function width(j: number): number { return curA1[j] - curA0[j]; }
// 시작 각과 끝 각을 따로 보간한다 — 이웃 조각이 경계를 함께 쓰므로 사이가 벌어지지 않는다
function apply(): void {
const e = easeInOutPow(morphMs / MORPH_MS, EASE_POW);
for (let j = 0; j < ITEM_N; j += 1) {
curA0[j] = lerp(fromA0[j], toA0[j], e);
curA1[j] = lerp(fromA1[j], toA1[j], e);
}
}
function report(): void {
if (pick >= 0 && width(pick) > 0.001) { sayPick(pick, width(pick) / TAU); return; }
say(PERIODS[period] + ' — ' + TXT_MORPH);
}
function goPeriod(k: number): void {
// 지금 보이는 각에서 출발한다 — 바꾸는 도중에 눌러도 튀지 않는다
fromA0 = curA0.slice();
fromA1 = curA1.slice();
const t = anglesOf(SETS[k]);
toA0 = t[0];
toA1 = t[1];
period = k;
morphMs = 0;
if (pick >= 0 && SETS[k][pick] === 0) { pick = -1; }
for (let b = 0; b < periodBtns.length; b += 1) {
const btn = periodBtns[b];
if (btn) { btn.setAttribute('aria-pressed', b === k ? 'true' : 'false'); }
}
}
function step(): void {
if (morphMs >= MORPH_MS) { return; }
morphMs = Math.min(MORPH_MS, morphMs + FIXED_DT);
apply();
}
function settle(): void {
morphMs = MORPH_MS;
apply();
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 1000], function (): void {
goPeriod((period + 1) % DATA_SETS);
report();
});
}
function draw(): void {
clearStage();
ctx.fillStyle = rgba(PANEL, 1);
ctx.beginPath();
ctx.arc(CX, CY, R, 0, TAU);
ctx.fill();
for (let j = 0; j < ITEM_N; j += 1) {
if (width(j) < 0.0005) { continue; }
const r = R + (j === pick ? 6 : 0);
ctx.fillStyle = rgba(pick >= 0 && j !== pick ? mix(PAL[j], BG, 0.38) : PAL[j], 1);
wedgePath(ctx, CX, CY, 0, r, curA0[j], curA1[j]);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
for (let j = 0; j < ITEM_N; j += 1) {
if (width(j) < 0.4) { continue; }
const mid = (curA0[j] + curA1[j]) / 2;
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillText(pctText(width(j) / TAU), CX + dirX(mid) * R * 0.64, CY + dirY(mid) * R * 0.64);
}
for (let j = 0; j < ITEM_N; j += 1) {
const w = width(j);
legendRow(j, LABELS[j], w > 0.001 ? pctText(w / TAU) : '-', PAL[j], j === pick, w <= 0.001);
}
statRow(212, '기간', PERIODS[period]);
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function movePick(d: number): void {
let j = pick;
for (let n = 0; n < ITEM_N; n += 1) {
j = j < 0 ? (d > 0 ? 0 : ITEM_N - 1) : (j + d + ITEM_N) % ITEM_N;
if (SETS[period][j] > 0) { break; }
}
pick = j;
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) <= R + 8) {
const a = angleAt(p[0], p[1], CX, CY);
pick = -1;
for (let j = 0; j < ITEM_N; j += 1) { if (a >= curA0[j] && a < curA1[j]) { pick = j; } }
} else {
goPeriod((period + 1) % DATA_SETS);
}
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); wake(); goPeriod((period + 1) % DATA_SETS); after(); }
else if (ev.key === '1' || ev.key === '2' || ev.key === '3') { ev.preventDefault(); wake(); goPeriod(Number(ev.key) - 1); after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; after(); }
});
for (let b = 0; b < periodBtns.length; b += 1) {
const btn = periodBtns[b];
if (btn) { btn.addEventListener('click', function (): void { wake(); goPeriod(b); after(); }); }
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="기간을 바꾸면 같은 항목끼리 시작 각과 끝 각을 이어 새 비율로 바뀌는 원형 그래프. Enter 나 1·2·3 으로 기간을 바꾸고 ←/→ 로 조각을 고른다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">같은 항목끼리 각도를 이어 바꾸는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="p0" type="button" aria-pressed="true">기간 1</button>
<button className={cx("pc__btn")} data-role="p1" type="button" aria-pressed="false">기간 2</button>
<button className={cx("pc__btn")} data-role="p2" type="button" aria-pressed="false">기간 3</button>
</div>
</div>
);
}04 · 기간을 바꾸면 조각 각도가 이어지며 새 비율로 바뀌는 원형 그래프 — 측정 누적면적 23.678% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function anglesOf(values) {
let total = 0;
for (let j = 0; j < ITEM_N; j += 1) {
total += values[j];
}
const a0 = [];
const a1 = [];
let acc = 0;
for (let j = 0; j < ITEM_N; j += 1) {
a0.push((acc / total) * TAU);
acc += values[j];
a1.push((acc / total) * TAU);
}
return [a0, a1];
}
function apply() {
const e = easeInOutPow(morphMs / MORPH_MS, EASE_POW);
for (let j = 0; j < ITEM_N; j += 1) {
curA0[j] = lerp(fromA0[j], toA0[j], e);
curA1[j] = lerp(fromA1[j], toA1[j], e);
}
}
05바깥 라벨이 지시선을 달고 겹치면 위아래로 밀려나는 원형 그래프
1.4~3.3% 짜리 작은 조각 여섯 개가 12시 바로 뒤에 몰려 있어 꺾이는 점 높이에 라벨을 그대로 두면 다섯 쌍이 겹치고, relaxPass 가 좌우 무리마다 높이순으로 세워 간격이 16px 보다 좁은 이웃을 모자란 만큼의 반씩 위아래로 민다. 40ms 마다 한 번 밀어 15번째, 곧 600ms 만에 겹친 쌍이 0 이 되고, 가장 멀리 옮겨 간 라벨은 29.3px 를 움직인다. 항목이 많은 리포트에 맞고, 원은 그대로 두고 라벨만 움직이므로 렌더 수치는 누적면적 5.3%·강도 90.6·18/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="바깥 라벨이 지시선을 달고 겹치면 반씩 밀려나는 원형 그래프. Enter 로 다시 흩고 Space 로 한 번에 풀며 ←/→ 로 조각을 고른다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">겹친 라벨을 반씩 밀어내는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="scramble" type="button">다시 흩기</button>
<button class="pc__btn" data-role="solve" type="button">한 번에 풀기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 이웃 라벨 사이 최소 세로 간격(px)
const LABEL_GAP = 16;
// 밀어내기를 되풀이하는 최대 횟수 — 겹침이 먼저 풀리면 거기서 멈춘다
const RELAX_ITERS = 40;
// 조각 가장자리에서 꺾이는 점까지 지시선 길이(px)
const ELBOW_LEN = 14;
const RELAX_STEP_MS = 40;
const SLICE_N = 9;
const SEED = 467;
const CX = 300;
const CY = 150;
const R = 80;
const DEMO_LOOP = 2000;
// 라벨이 설 수 있는 높이 — 왼쪽은 버튼·상태 줄을 피한다
const LEFT_MIN = 78;
const LEFT_MAX = 236;
const RIGHT_MIN = 22;
const RIGHT_MAX = 282;
const PILL_H = 14;
const PILL_W = 66;
const TXT_RELAX = '겹친 라벨을 반씩 밀어내는 중';
const TXT_DONE = '라벨 겹침이 풀림';
const scrambleBtn = root.querySelector('[data-role="scramble"]');
const solveBtn = root.querySelector('[data-role="solve"]');
// 작은 조각 여섯을 12시 바로 뒤에 몰아 두고 큰 조각 셋을 그 뒤에 — 작은 조각들의 라벨이 오른쪽 위에 겹치게
function makeValues() {
const rnd = makeRng(SEED);
const out = [];
for (let i = 0; i < 6; i += 1) {
out.push(3 + Math.floor(rnd() * 5));
}
for (let i = 0; i < 3; i += 1) {
out.push(40 + Math.floor(rnd() * 30));
}
return out;
}
const slices = layout(makeValues());
const labels = [];
let pass = 0;
let passAcc = 0;
let solved = false;
let pick = -1;
function lo(side) { return side > 0 ? RIGHT_MIN : LEFT_MIN; }
function hi(side) { return side > 0 ? RIGHT_MAX : LEFT_MAX; }
// 첫 자리 — 조각 가운데 각에서 꺾이는 점의 높이. 오른쪽 반이면 오른쪽 무리, 아니면 왼쪽 무리
function placeLabels() {
labels.length = 0;
for (let i = 0; i < SLICE_N; i += 1) {
const mid = (slices[i].a0 + slices[i].a1) / 2;
const side = dirX(mid) >= 0 ? 1 : -1;
const y0 = CY + dirY(mid) * (R + ELBOW_LEN);
labels.push({ i: i, side: side, y0: y0, y: clamp(y0, lo(side), hi(side)) });
}
pass = 0;
passAcc = 0;
solved = false;
}
function group(side) {
return labels.filter(function (l) { return l.side === side; })
.sort(function (a, b) { return a.y - b.y; });
}
function overlapPairs() {
let n = 0;
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) {
if (g[j + 1].y - g[j].y < LABEL_GAP - 0.5) {
n += 1;
}
}
}
return n;
}
// 한 번 밀어내기 — 높이순으로 세운 이웃이 LABEL_GAP 보다 가까우면 모자란 만큼을 반씩 나눠 위아래로 민다
function relaxPass() {
let moved = 0;
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) {
const d = g[j + 1].y - g[j].y;
// 0.25px 안쪽까지 좁혀지면 다 민 것으로 친다 — 반씩 밀기는 간격에 한없이 다가가기만 하므로
if (d < LABEL_GAP - 0.25) {
const push = (LABEL_GAP - d) / 2;
g[j].y -= push;
g[j + 1].y += push;
moved += 1;
}
}
for (let j = 0; j < g.length; j += 1) {
g[j].y = clamp(g[j].y, lo(side), hi(side));
}
}
return moved;
}
function report() {
if (pick >= 0) {
sayPick(pick, slices[pick].share);
return;
}
say(solved ? TXT_DONE : TXT_RELAX);
}
function oneRound() {
if (solved) {
return;
}
if (pass >= RELAX_ITERS || relaxPass() === 0) {
solved = true;
return;
}
pass += 1;
}
// 시연은 겹친 첫 자리에서 출발한다 — 200ms 에 밀어내기 시작, 1100ms 에 다시 흩어 한 번 더
let relaxing = !demo;
function step() {
if (solved || !relaxing) {
return;
}
passAcc += FIXED_DT;
while (passAcc >= RELAX_STEP_MS && !solved) {
passAcc -= RELAX_STEP_MS;
oneRound();
}
}
function settle() {
while (!solved) {
oneRound();
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [0, 200, 1100], function (k) {
if (k === 0 || k === 2) {
placeLabels();
}
relaxing = k !== 0;
report();
});
}
function labelX(side) { return side > 0 ? CX + R + ELBOW_LEN + 12 : CX - R - ELBOW_LEN - 12; }
function draw() {
clearStage();
for (let i = 0; i < SLICE_N; i += 1) {
const r = R + (i === pick ? 5 : 0);
ctx.fillStyle = rgba(pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : PAL[i], 1);
wedgePath(ctx, CX, CY, 0, r, slices[i].a0, slices[i].a1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 겹친 라벨 표시 — 이웃과 LABEL_GAP 보다 가까운 라벨은 분홍 바탕
const tight = labels.map(function () { return false; });
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) {
if (g[j + 1].y - g[j].y < LABEL_GAP - 0.5) {
tight[g[j].i] = true;
tight[g[j + 1].i] = true;
}
}
}
for (let n = 0; n < labels.length; n += 1) {
const l = labels[n];
const s = slices[l.i];
const mid = (s.a0 + s.a1) / 2;
const x0 = CX + dirX(mid) * R;
const y0 = CY + dirY(mid) * R;
const x1 = CX + dirX(mid) * (R + ELBOW_LEN);
const lx = labelX(l.side);
// 지시선 — 가장자리 → 꺾이는 점 → 라벨 높이로 비스듬히 → 라벨 앞까지 가로
ctx.strokeStyle = rgba(l.i === pick ? PAPER : mix(PAL[l.i], PAPER, 0.3), l.i === pick ? 1 : 0.8);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, l.y0);
ctx.lineTo(lx - l.side * 6, l.y);
ctx.lineTo(lx, l.y);
ctx.stroke();
const px = l.side > 0 ? lx : lx - PILL_W;
ctx.fillStyle = tight[l.i] ? rgba(PAL[1], 0.95) : rgba(l.i === pick ? PAPER : PANEL, 1);
ctx.fillRect(px, l.y - PILL_H / 2, PILL_W, PILL_H);
ctx.fillStyle = rgba(PAL[l.i], 1);
ctx.fillRect(px + 4, l.y - 3, 6, 6);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '600 10px sans-serif';
ctx.fillStyle = tight[l.i] || l.i === pick ? rgba(INK, 1) : rgba(PAPER, 0.92);
ctx.fillText(LABELS[l.i] + ' ' + pctText(s.share), px + 13, l.y + 0.5);
}
statRow(110, '되풀이', String(pass) + ' / ' + String(RELAX_ITERS));
statRow(156, '겹친 쌍', String(overlapPairs()));
statRow(202, '최소 간격', String(LABEL_GAP) + ' px');
}
function after() {
relaxing = true;
if (still) {
settle();
}
report();
draw();
}
function scramble() {
placeLabels();
after();
}
function movePick(d) {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
placeLabels();
if (!demo) {
settle();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) <= R + 6) {
const i = sliceAt(slices, angleAt(p[0], p[1], CX, CY));
pick = i === pick ? -1 : i;
after();
}
else {
scramble();
}
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter') {
ev.preventDefault();
wake();
scramble();
}
else if (ev.key === ' ') {
ev.preventDefault();
wake();
settle();
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
after();
}
});
if (scrambleBtn) {
scrambleBtn.addEventListener('click', function () { wake(); scramble(); });
}
if (solveBtn) {
solveBtn.addEventListener('click', function () { wake(); settle(); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieLabelLeaderRelax - vanilla 판(05-pie-chart-label-leader-relax) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieLabelLeaderRelax.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieLabelLeaderRelax() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 이웃 라벨 사이 최소 세로 간격(px)
const LABEL_GAP = 16;
// 밀어내기를 되풀이하는 최대 횟수 — 겹침이 먼저 풀리면 거기서 멈춘다
const RELAX_ITERS = 40;
// 조각 가장자리에서 꺾이는 점까지 지시선 길이(px)
const ELBOW_LEN = 14;
const RELAX_STEP_MS = 40;
const SLICE_N = 9;
const SEED = 467;
const CX = 300;
const CY = 150;
const R = 80;
const DEMO_LOOP = 2000;
// 라벨이 설 수 있는 높이 — 왼쪽은 버튼·상태 줄을 피한다
const LEFT_MIN = 78;
const LEFT_MAX = 236;
const RIGHT_MIN = 22;
const RIGHT_MAX = 282;
const PILL_H = 14;
const PILL_W = 66;
const TXT_RELAX = '겹친 라벨을 반씩 밀어내는 중';
const TXT_DONE = '라벨 겹침이 풀림';
const scrambleBtn = root.querySelector('[data-role="scramble"]') as HTMLButtonElement | null;
const solveBtn = root.querySelector('[data-role="solve"]') as HTMLButtonElement | null;
// 작은 조각 여섯을 12시 바로 뒤에 몰아 두고 큰 조각 셋을 그 뒤에 — 작은 조각들의 라벨이 오른쪽 위에 겹치게
function makeValues(): number[] {
const rnd = makeRng(SEED);
const out: number[] = [];
for (let i = 0; i < 6; i += 1) { out.push(3 + Math.floor(rnd() * 5)); }
for (let i = 0; i < 3; i += 1) { out.push(40 + Math.floor(rnd() * 30)); }
return out;
}
const slices: Slice[] = layout(makeValues());
type Label = { i: number; side: number; y0: number; y: number };
const labels: Label[] = [];
let pass = 0;
let passAcc = 0;
let solved = false;
let pick = -1;
function lo(side: number): number { return side > 0 ? RIGHT_MIN : LEFT_MIN; }
function hi(side: number): number { return side > 0 ? RIGHT_MAX : LEFT_MAX; }
// 첫 자리 — 조각 가운데 각에서 꺾이는 점의 높이. 오른쪽 반이면 오른쪽 무리, 아니면 왼쪽 무리
function placeLabels(): void {
labels.length = 0;
for (let i = 0; i < SLICE_N; i += 1) {
const mid = (slices[i].a0 + slices[i].a1) / 2;
const side = dirX(mid) >= 0 ? 1 : -1;
const y0 = CY + dirY(mid) * (R + ELBOW_LEN);
labels.push({ i: i, side: side, y0: y0, y: clamp(y0, lo(side), hi(side)) });
}
pass = 0;
passAcc = 0;
solved = false;
}
function group(side: number): Label[] {
return labels.filter(function (l: Label): boolean { return l.side === side; })
.sort(function (a: Label, b: Label): number { return a.y - b.y; });
}
function overlapPairs(): number {
let n = 0;
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) { if (g[j + 1].y - g[j].y < LABEL_GAP - 0.5) { n += 1; } }
}
return n;
}
// 한 번 밀어내기 — 높이순으로 세운 이웃이 LABEL_GAP 보다 가까우면 모자란 만큼을 반씩 나눠 위아래로 민다
function relaxPass(): number {
let moved = 0;
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) {
const d = g[j + 1].y - g[j].y;
// 0.25px 안쪽까지 좁혀지면 다 민 것으로 친다 — 반씩 밀기는 간격에 한없이 다가가기만 하므로
if (d < LABEL_GAP - 0.25) {
const push = (LABEL_GAP - d) / 2;
g[j].y -= push;
g[j + 1].y += push;
moved += 1;
}
}
for (let j = 0; j < g.length; j += 1) { g[j].y = clamp(g[j].y, lo(side), hi(side)); }
}
return moved;
}
function report(): void {
if (pick >= 0) { sayPick(pick, slices[pick].share); return; }
say(solved ? TXT_DONE : TXT_RELAX);
}
function oneRound(): void {
if (solved) { return; }
if (pass >= RELAX_ITERS || relaxPass() === 0) { solved = true; return; }
pass += 1;
}
// 시연은 겹친 첫 자리에서 출발한다 — 200ms 에 밀어내기 시작, 1100ms 에 다시 흩어 한 번 더
let relaxing = !demo;
function step(): void {
if (solved || !relaxing) { return; }
passAcc += FIXED_DT;
while (passAcc >= RELAX_STEP_MS && !solved) {
passAcc -= RELAX_STEP_MS;
oneRound();
}
}
function settle(): void {
while (!solved) { oneRound(); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [0, 200, 1100], function (k: number): void {
if (k === 0 || k === 2) { placeLabels(); }
relaxing = k !== 0;
report();
});
}
function labelX(side: number): number { return side > 0 ? CX + R + ELBOW_LEN + 12 : CX - R - ELBOW_LEN - 12; }
function draw(): void {
clearStage();
for (let i = 0; i < SLICE_N; i += 1) {
const r = R + (i === pick ? 5 : 0);
ctx.fillStyle = rgba(pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : PAL[i], 1);
wedgePath(ctx, CX, CY, 0, r, slices[i].a0, slices[i].a1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 겹친 라벨 표시 — 이웃과 LABEL_GAP 보다 가까운 라벨은 분홍 바탕
const tight: boolean[] = labels.map(function (): boolean { return false; });
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) {
if (g[j + 1].y - g[j].y < LABEL_GAP - 0.5) { tight[g[j].i] = true; tight[g[j + 1].i] = true; }
}
}
for (let n = 0; n < labels.length; n += 1) {
const l = labels[n];
const s = slices[l.i];
const mid = (s.a0 + s.a1) / 2;
const x0 = CX + dirX(mid) * R;
const y0 = CY + dirY(mid) * R;
const x1 = CX + dirX(mid) * (R + ELBOW_LEN);
const lx = labelX(l.side);
// 지시선 — 가장자리 → 꺾이는 점 → 라벨 높이로 비스듬히 → 라벨 앞까지 가로
ctx.strokeStyle = rgba(l.i === pick ? PAPER : mix(PAL[l.i], PAPER, 0.3), l.i === pick ? 1 : 0.8);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, l.y0);
ctx.lineTo(lx - l.side * 6, l.y);
ctx.lineTo(lx, l.y);
ctx.stroke();
const px = l.side > 0 ? lx : lx - PILL_W;
ctx.fillStyle = tight[l.i] ? rgba(PAL[1], 0.95) : rgba(l.i === pick ? PAPER : PANEL, 1);
ctx.fillRect(px, l.y - PILL_H / 2, PILL_W, PILL_H);
ctx.fillStyle = rgba(PAL[l.i], 1);
ctx.fillRect(px + 4, l.y - 3, 6, 6);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '600 10px sans-serif';
ctx.fillStyle = tight[l.i] || l.i === pick ? rgba(INK, 1) : rgba(PAPER, 0.92);
ctx.fillText(LABELS[l.i] + ' ' + pctText(s.share), px + 13, l.y + 0.5);
}
statRow(110, '되풀이', String(pass) + ' / ' + String(RELAX_ITERS));
statRow(156, '겹친 쌍', String(overlapPairs()));
statRow(202, '최소 간격', String(LABEL_GAP) + ' px');
}
function after(): void {
relaxing = true;
if (still) { settle(); }
report();
draw();
}
function scramble(): void {
placeLabels();
after();
}
function movePick(d: number): void {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
placeLabels();
if (!demo) { settle(); }
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) <= R + 6) {
const i = sliceAt(slices, angleAt(p[0], p[1], CX, CY));
pick = i === pick ? -1 : i;
after();
} else {
scramble();
}
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter') { ev.preventDefault(); wake(); scramble(); }
else if (ev.key === ' ') { ev.preventDefault(); wake(); settle(); after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; after(); }
});
if (scrambleBtn) { scrambleBtn.addEventListener('click', function (): void { wake(); scramble(); }); }
if (solveBtn) { solveBtn.addEventListener('click', function (): void { wake(); settle(); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="바깥 라벨이 지시선을 달고 겹치면 반씩 밀려나는 원형 그래프. Enter 로 다시 흩고 Space 로 한 번에 풀며 ←/→ 로 조각을 고른다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">겹친 라벨을 반씩 밀어내는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="scramble" type="button">다시 흩기</button>
<button className={cx("pc__btn")} data-role="solve" type="button">한 번에 풀기</button>
</div>
</div>
);
}05 · 바깥 라벨이 지시선을 달고 겹치면 위아래로 밀려나는 원형 그래프 — 측정 누적면적 5.303% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function relaxPass() {
let moved = 0;
for (const side of [-1, 1]) {
const g = group(side);
for (let j = 0; j + 1 < g.length; j += 1) {
const d = g[j + 1].y - g[j].y;
// 0.25px 안쪽까지 좁혀지면 다 민 것으로 친다 — 반씩 밀기는 간격에 한없이 다가가기만 하므로
if (d < LABEL_GAP - 0.25) {
const push = (LABEL_GAP - d) / 2;
g[j].y -= push;
g[j + 1].y += push;
moved += 1;
}
}
for (let j = 0; j < g.length; j += 1) {
g[j].y = clamp(g[j].y, lo(side), hi(side));
}
}
return moved;
}
06원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환
조각 테두리를 가운데에서 시작 각까지 12점, 호 24점, 끝 각에서 가운데로 12점, 합쳐 48점으로 뽑고, 막대 테두리도 왼쪽 변 12·윗변 24·오른쪽 변 12 로 같은 수를 같은 방향으로 뽑는다. 점 k 끼리 직선으로 보간하니 극좌표의 부채꼴이 직교 좌표의 막대로 옮겨 가고, 조각마다 50ms 씩 늦게 출발해 한 조각이 450ms 동안 움직인다. 비율과 크기 비교를 한 카드에서 바꿔 보여 줄 때 쓰고, 렌더 수치는 누적면적 26.3%·강도 156.5·19/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환. Enter 로 원과 막대를 오가고 ←/→ 로 조각을 고른다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">원형 — 비율로 보기</p>
<div class="pc__tools">
<button class="pc__btn" data-role="toggle" type="button" aria-pressed="false">막대로 풀기</button>
<button class="pc__btn" data-role="data" type="button">새 데이터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 원 ↔ 막대 한 번 오가는 시간
const UNROLL_MS = 700;
// 막대 사이 간격(px)
const BAR_GAP = 10;
// 조각 하나의 테두리에서 뽑는 점 수 — 막대 둘레도 같은 수로 만들어 점끼리 짝짓는다
const OUTLINE_SAMPLES = 48;
const STAGGER_MS = 50;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 104;
const BAR_X0 = 184;
const BAR_X1 = 464;
const BASE_Y = 262;
const BAR_MAX_H = 196;
const DEMO_LOOP = 2000;
const TXT_PIE = '원형 — 비율로 보기';
const TXT_BARS = '막대 — 크기로 보기';
const toggleBtn = root.querySelector('[data-role="toggle"]');
const dataBtn = root.querySelector('[data-role="data"]');
let seed = SEED;
let slices = [];
let pieOut = [];
let barOut = [];
let bars = false;
let pos = 0;
let pick = -1;
// 부채꼴 테두리 — 가운데에서 시작 각으로 4분의 1, 호를 따라 절반, 끝 각에서 가운데로 4분의 1
function wedgeOutline(s) {
const n = OUTLINE_SAMPLES;
const q = Math.floor(n / 4);
const out = [];
for (let k = 0; k < n; k += 1) {
let r = R;
let a = s.a0;
if (k < q) {
r = (R * k) / q;
}
else if (k < n - q) {
a = s.a0 + ((s.a1 - s.a0) * (k - q)) / (n - 2 * q);
}
else {
r = R * (1 - (k - (n - q)) / q);
a = s.a1;
}
out.push([CX + dirX(a) * r, CY + dirY(a) * r]);
}
return out;
}
// 막대 둘레 — 왼쪽 변을 아래에서 위로, 윗변을 왼쪽에서 오른쪽으로, 오른쪽 변을 위에서 아래로 (아랫변은 닫는 선)
function barOutline(i, value, maxV) {
const n = OUTLINE_SAMPLES;
const q = Math.floor(n / 4);
const bw = (BAR_X1 - BAR_X0 - (SLICE_N - 1) * BAR_GAP) / SLICE_N;
const x0 = BAR_X0 + i * (bw + BAR_GAP);
const x1 = x0 + bw;
const yt = BASE_Y - (value / maxV) * BAR_MAX_H;
const out = [];
for (let k = 0; k < n; k += 1) {
if (k < q) {
out.push([x0, BASE_Y - ((BASE_Y - yt) * k) / q]);
}
else if (k < n - q) {
out.push([x0 + ((x1 - x0) * (k - q)) / (n - 2 * q), yt]);
}
else {
out.push([x1, yt + ((BASE_Y - yt) * (k - (n - q))) / q]);
}
}
return out;
}
function setData(s) {
seed = s;
const values = seededValues(seed, SLICE_N, 8, 40);
slices = layout(values);
let maxV = 0;
for (let i = 0; i < SLICE_N; i += 1) {
maxV = Math.max(maxV, values[i]);
}
pieOut = slices.map(function (sl) { return wedgeOutline(sl); });
barOut = slices.map(function (sl, i) { return barOutline(i, sl.value, maxV); });
}
setData(SEED);
// 조각마다 늦게 출발한 진행률 — 0 이면 원, 1 이면 막대
function sliceT(i) {
const span = UNROLL_MS - (SLICE_N - 1) * STAGGER_MS;
return smooth((pos - i * STAGGER_MS) / span);
}
function report() {
if (pick >= 0) {
sayPick(pick, slices[pick].share);
return;
}
say(bars ? TXT_BARS : TXT_PIE);
}
function step() {
const to = bars ? UNROLL_MS : 0;
if (pos === to) {
return;
}
pos = bars ? Math.min(to, pos + FIXED_DT) : Math.max(0, pos - FIXED_DT);
}
function settle() { pos = bars ? UNROLL_MS : 0; }
function setBars(on) {
bars = on;
if (toggleBtn) {
toggleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 1100], function (k) {
setBars(k === 0);
report();
});
}
function draw() {
clearStage();
const gAll = pos / UNROLL_MS;
// 막대 바닥선 — 막대 쪽으로 갈수록 진해진다
ctx.strokeStyle = rgba(PAPER, 0.5 * gAll);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(BAR_X0 - 4, BASE_Y + 0.5);
ctx.lineTo(BAR_X1 + 4, BASE_Y + 0.5);
ctx.stroke();
for (let i = 0; i < SLICE_N; i += 1) {
const e = sliceT(i);
const A = pieOut[i];
const B = barOut[i];
// 점마다 원 위치와 막대 위치를 같은 진행률로 보간한다
ctx.beginPath();
for (let k = 0; k < A.length; k += 1) {
const x = lerp(A[k][0], B[k][0], e);
const y = lerp(A[k][1], B[k][1], e);
if (k === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fillStyle = rgba(pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : (i === pick ? mix(PAL[i], PAPER, 0.2) : PAL[i]), 1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
ctx.fillStyle = rgba(INK, 0.9);
for (let i = 0; i < SLICE_N; i += 1) {
const e = sliceT(i);
const s = slices[i];
if (e <= 0.02 && s.share >= 0.07) {
const mid = (s.a0 + s.a1) / 2;
ctx.fillText(pctText(s.share), CX + dirX(mid) * R * 0.64, CY + dirY(mid) * R * 0.64);
}
else if (e >= 0.98) {
const B = barOut[i];
const q = Math.floor(OUTLINE_SAMPLES / 4);
ctx.fillStyle = rgba(PAPER, 0.92);
ctx.fillText(String(s.value), (B[q][0] + B[OUTLINE_SAMPLES - q][0]) / 2, B[q][1] - 9);
ctx.fillStyle = rgba(INK, 0.9);
}
}
for (let i = 0; i < SLICE_N; i += 1) {
legendRow(i, LABELS[i], bars ? String(slices[i].value) : pctText(slices[i].share), PAL[i], i === pick, false);
}
statRow(212, '점 수 · 막대 간격', String(OUTLINE_SAMPLES) + ' · ' + String(BAR_GAP) + ' px');
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function hitSlice(x, y) {
if (pos < UNROLL_MS / 2) {
return Math.hypot(x - CX, y - CY) <= R + 6 ? sliceAt(slices, angleAt(x, y, CX, CY)) : -1;
}
for (let i = 0; i < SLICE_N; i += 1) {
const B = barOut[i];
const q = Math.floor(OUTLINE_SAMPLES / 4);
if (x >= B[0][0] - 2 && x <= B[OUTLINE_SAMPLES - 1][0] + 2 && y >= B[q][1] - 6 && y <= BASE_Y + 4) {
return i;
}
}
return -1;
}
function movePick(d) {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const i = hitSlice(p[0], p[1]);
if (i >= 0) {
pick = i === pick ? -1 : i;
}
else {
setBars(!bars);
}
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
setBars(!bars);
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
after();
}
});
if (toggleBtn) {
toggleBtn.addEventListener('click', function () { wake(); setBars(!bars); after(); });
}
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); setData(seed + 1); pick = -1; after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieUnrollToBars - vanilla 판(06-pie-chart-unroll-to-bars) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieUnrollToBars.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieUnrollToBars() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 원 ↔ 막대 한 번 오가는 시간
const UNROLL_MS = 700;
// 막대 사이 간격(px)
const BAR_GAP = 10;
// 조각 하나의 테두리에서 뽑는 점 수 — 막대 둘레도 같은 수로 만들어 점끼리 짝짓는다
const OUTLINE_SAMPLES = 48;
const STAGGER_MS = 50;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 104;
const BAR_X0 = 184;
const BAR_X1 = 464;
const BASE_Y = 262;
const BAR_MAX_H = 196;
const DEMO_LOOP = 2000;
const TXT_PIE = '원형 — 비율로 보기';
const TXT_BARS = '막대 — 크기로 보기';
const toggleBtn = root.querySelector('[data-role="toggle"]') as HTMLButtonElement | null;
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
type Pt = [number, number];
let seed = SEED;
let slices: Slice[] = [];
let pieOut: Pt[][] = [];
let barOut: Pt[][] = [];
let bars = false;
let pos = 0;
let pick = -1;
// 부채꼴 테두리 — 가운데에서 시작 각으로 4분의 1, 호를 따라 절반, 끝 각에서 가운데로 4분의 1
function wedgeOutline(s: Slice): Pt[] {
const n = OUTLINE_SAMPLES;
const q = Math.floor(n / 4);
const out: Pt[] = [];
for (let k = 0; k < n; k += 1) {
let r = R;
let a = s.a0;
if (k < q) { r = (R * k) / q; }
else if (k < n - q) { a = s.a0 + ((s.a1 - s.a0) * (k - q)) / (n - 2 * q); }
else { r = R * (1 - (k - (n - q)) / q); a = s.a1; }
out.push([CX + dirX(a) * r, CY + dirY(a) * r]);
}
return out;
}
// 막대 둘레 — 왼쪽 변을 아래에서 위로, 윗변을 왼쪽에서 오른쪽으로, 오른쪽 변을 위에서 아래로 (아랫변은 닫는 선)
function barOutline(i: number, value: number, maxV: number): Pt[] {
const n = OUTLINE_SAMPLES;
const q = Math.floor(n / 4);
const bw = (BAR_X1 - BAR_X0 - (SLICE_N - 1) * BAR_GAP) / SLICE_N;
const x0 = BAR_X0 + i * (bw + BAR_GAP);
const x1 = x0 + bw;
const yt = BASE_Y - (value / maxV) * BAR_MAX_H;
const out: Pt[] = [];
for (let k = 0; k < n; k += 1) {
if (k < q) { out.push([x0, BASE_Y - ((BASE_Y - yt) * k) / q]); }
else if (k < n - q) { out.push([x0 + ((x1 - x0) * (k - q)) / (n - 2 * q), yt]); }
else { out.push([x1, yt + ((BASE_Y - yt) * (k - (n - q))) / q]); }
}
return out;
}
function setData(s: number): void {
seed = s;
const values = seededValues(seed, SLICE_N, 8, 40);
slices = layout(values);
let maxV = 0;
for (let i = 0; i < SLICE_N; i += 1) { maxV = Math.max(maxV, values[i]); }
pieOut = slices.map(function (sl: Slice): Pt[] { return wedgeOutline(sl); });
barOut = slices.map(function (sl: Slice, i: number): Pt[] { return barOutline(i, sl.value, maxV); });
}
setData(SEED);
// 조각마다 늦게 출발한 진행률 — 0 이면 원, 1 이면 막대
function sliceT(i: number): number {
const span = UNROLL_MS - (SLICE_N - 1) * STAGGER_MS;
return smooth((pos - i * STAGGER_MS) / span);
}
function report(): void {
if (pick >= 0) { sayPick(pick, slices[pick].share); return; }
say(bars ? TXT_BARS : TXT_PIE);
}
function step(): void {
const to = bars ? UNROLL_MS : 0;
if (pos === to) { return; }
pos = bars ? Math.min(to, pos + FIXED_DT) : Math.max(0, pos - FIXED_DT);
}
function settle(): void { pos = bars ? UNROLL_MS : 0; }
function setBars(on: boolean): void {
bars = on;
if (toggleBtn) { toggleBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); }
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 1100], function (k: number): void {
setBars(k === 0);
report();
});
}
function draw(): void {
clearStage();
const gAll = pos / UNROLL_MS;
// 막대 바닥선 — 막대 쪽으로 갈수록 진해진다
ctx.strokeStyle = rgba(PAPER, 0.5 * gAll);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(BAR_X0 - 4, BASE_Y + 0.5);
ctx.lineTo(BAR_X1 + 4, BASE_Y + 0.5);
ctx.stroke();
for (let i = 0; i < SLICE_N; i += 1) {
const e = sliceT(i);
const A = pieOut[i];
const B = barOut[i];
// 점마다 원 위치와 막대 위치를 같은 진행률로 보간한다
ctx.beginPath();
for (let k = 0; k < A.length; k += 1) {
const x = lerp(A[k][0], B[k][0], e);
const y = lerp(A[k][1], B[k][1], e);
if (k === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.closePath();
ctx.fillStyle = rgba(pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : (i === pick ? mix(PAL[i], PAPER, 0.2) : PAL[i]), 1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 12px sans-serif';
ctx.fillStyle = rgba(INK, 0.9);
for (let i = 0; i < SLICE_N; i += 1) {
const e = sliceT(i);
const s = slices[i];
if (e <= 0.02 && s.share >= 0.07) {
const mid = (s.a0 + s.a1) / 2;
ctx.fillText(pctText(s.share), CX + dirX(mid) * R * 0.64, CY + dirY(mid) * R * 0.64);
} else if (e >= 0.98) {
const B = barOut[i];
const q = Math.floor(OUTLINE_SAMPLES / 4);
ctx.fillStyle = rgba(PAPER, 0.92);
ctx.fillText(String(s.value), (B[q][0] + B[OUTLINE_SAMPLES - q][0]) / 2, B[q][1] - 9);
ctx.fillStyle = rgba(INK, 0.9);
}
}
for (let i = 0; i < SLICE_N; i += 1) {
legendRow(i, LABELS[i], bars ? String(slices[i].value) : pctText(slices[i].share), PAL[i], i === pick, false);
}
statRow(212, '점 수 · 막대 간격', String(OUTLINE_SAMPLES) + ' · ' + String(BAR_GAP) + ' px');
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function hitSlice(x: number, y: number): number {
if (pos < UNROLL_MS / 2) {
return Math.hypot(x - CX, y - CY) <= R + 6 ? sliceAt(slices, angleAt(x, y, CX, CY)) : -1;
}
for (let i = 0; i < SLICE_N; i += 1) {
const B = barOut[i];
const q = Math.floor(OUTLINE_SAMPLES / 4);
if (x >= B[0][0] - 2 && x <= B[OUTLINE_SAMPLES - 1][0] + 2 && y >= B[q][1] - 6 && y <= BASE_Y + 4) { return i; }
}
return -1;
}
function movePick(d: number): void {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const i = hitSlice(p[0], p[1]);
if (i >= 0) { pick = i === pick ? -1 : i; } else { setBars(!bars); }
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); wake(); setBars(!bars); after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; after(); }
});
if (toggleBtn) { toggleBtn.addEventListener('click', function (): void { wake(); setBars(!bars); after(); }); }
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); setData(seed + 1); pick = -1; after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환. Enter 로 원과 막대를 오가고 ←/→ 로 조각을 고른다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">원형 — 비율로 보기</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="toggle" type="button" aria-pressed="false">막대로 풀기</button>
<button className={cx("pc__btn")} data-role="data" type="button">새 데이터</button>
</div>
</div>
);
}06 · 원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환 — 측정 누적면적 26.331% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function wedgeOutline(s) {
const n = OUTLINE_SAMPLES;
const q = Math.floor(n / 4);
const out = [];
for (let k = 0; k < n; k += 1) {
let r = R;
let a = s.a0;
if (k < q) {
r = (R * k) / q;
}
else if (k < n - q) {
a = s.a0 + ((s.a1 - s.a0) * (k - q)) / (n - 2 * q);
}
else {
r = R * (1 - (k - (n - q)) / q);
a = s.a1;
}
out.push([CX + dirX(a) * r, CY + dirY(a) * r]);
}
return out;
}
07안쪽 원과 바깥 고리로 나뉘고 누르면 그 갈래가 한 바퀴로 펼쳐지는 선버스트
묶음 4·9·22개 마디의 범위를 각이 아니라 0~1 비율로 저장해 split 이 부모 범위를 자식 값 비율로 다시 나누고, 그릴 때 mapA 가 보고 있는 범위 [v0, v1] 를 0~2π 로 늘이며 범위 밖 조각은 양 끝에 폭 0 으로 붙인다. 고리를 누르면 v0·v1 이 650ms 동안 보간돼, 시연에서는 가장 큰 B 갈래(29.0%)의 각이 3.446배로 늘어나 한 바퀴를 채운다. 카테고리 아래 하위 카테고리가 딸린 구성비에 맞고, 렌더 수치는 누적면적 26.0%·강도 79.8·18/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="안쪽 고리와 바깥 고리로 나뉜 선버스트. 고리를 누르거나 Enter 로 갈래를 한 바퀴로 펼치고 Esc 로 한 단계 위로 간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">전체 — 고리를 눌러 갈래 펼치기</p>
<div class="pc__tools">
<button class="pc__btn" data-role="up" type="button">한 단계 위로</button>
<button class="pc__btn" data-role="home" type="button">처음으로</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 고리 한 겹의 두께(px)
const RING_WIDTH = 34;
// 갈래를 펼치거나 되돌리는 시간
const DRILL_MS = 650;
// 나무의 깊이 — 묶음 · 하위 묶음 · 낱개, 고리 세 겹
const LEVELS = 3;
const HOLE_R = 28;
const SEED = 467;
const CX = 316;
const CY = 150;
const DEMO_LOOP = 2000;
const TXT_ROOT = '전체 — 고리를 눌러 갈래 펼치기';
const upBtn = root.querySelector('[data-role="up"]');
const homeBtn = root.querySelector('[data-role="home"]');
// 가상 나무 — 맨 위 묶음 넷, 그 아래 둘이나 셋씩, 맨 끝 낱개 값은 시드로
function buildTree() {
const rnd = makeRng(SEED);
const top = { code: '', top: -1, depth: 0, value: 0, f0: 0, f1: 1, parent: null, kids: [] };
function grow(n) {
if (n.depth >= LEVELS) {
n.value = 4 + Math.floor(rnd() * 17);
return;
}
const k = n.depth === 0 ? 4 : 2 + Math.floor(rnd() * 2);
for (let i = 0; i < k; i += 1) {
const code = n.depth === 0 ? String.fromCharCode(65 + i) : n.code + '-' + String(i + 1);
const c = { code: code, top: n.depth === 0 ? i : n.top, depth: n.depth + 1, value: 0, f0: 0, f1: 0, parent: n, kids: [] };
n.kids.push(c);
grow(c);
n.value += c.value;
}
}
// 부모의 범위 [f0, f1] 를 자식 값의 비율로 다시 나눈다 — 바깥 고리가 안쪽 조각 안에 들어맞는 까닭
function split(n) {
let acc = n.f0;
for (let i = 0; i < n.kids.length; i += 1) {
const c = n.kids[i];
c.f0 = acc;
acc += ((n.f1 - n.f0) * c.value) / n.value;
c.f1 = acc;
split(c);
}
}
grow(top);
split(top);
return top;
}
const tree = buildTree();
const nodes = [];
(function collect(n) {
for (let i = 0; i < n.kids.length; i += 1) {
nodes.push(n.kids[i]);
collect(n.kids[i]);
}
})(tree);
let focus = tree;
let v0 = 0;
let v1 = 1;
let from0 = 0;
let from1 = 1;
let drillMs = DRILL_MS;
let pick = -1;
// 비율 → 각 — 보고 있는 범위 [v0, v1] 를 0 ~ 2π 로 늘인다. 범위 밖은 양 끝에 붙어 폭 0 이 된다
function mapA(f) { return clamp((f - v0) / (v1 - v0), 0, 1) * TAU; }
function nameOf(n) { return n.depth === 1 ? LABELS[n.top] : n.code; }
function ring0(d) { return HOLE_R + (d - 1) * RING_WIDTH; }
function report() {
if (pick >= 0 && pick < focus.kids.length) {
const k = focus.kids[pick];
say(nameOf(k) + ' · ' + pctText(k.f1 - k.f0));
return;
}
if (focus === tree) {
say(TXT_ROOT);
}
else {
say(nameOf(focus) + ' · ' + pctText(focus.f1 - focus.f0));
}
}
function drill(n) {
from0 = v0;
from1 = v1;
focus = n;
drillMs = 0;
pick = -1;
}
function step() {
if (drillMs >= DRILL_MS) {
return;
}
drillMs = Math.min(DRILL_MS, drillMs + FIXED_DT);
const e = easeInOutPow(drillMs / DRILL_MS, 3);
v0 = lerp(from0, focus.f0, e);
v1 = lerp(from1, focus.f1, e);
}
function settle() {
drillMs = DRILL_MS;
v0 = focus.f0;
v1 = focus.f1;
}
function biggest(n) {
let b = n.kids[0];
for (let i = 1; i < n.kids.length; i += 1) {
if (n.kids[i].value > b.value) {
b = n.kids[i];
}
}
return b;
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 1100], function (k) {
drill(k === 0 ? biggest(tree) : tree);
report();
});
}
function colorOf(n, idx) {
const c = PAL[n.top];
if (n.depth === 1) {
return c;
}
if (n.depth === 2) {
return mix(c, idx % 2 === 0 ? PAPER : BG, idx % 2 === 0 ? 0.22 : 0.12);
}
return mix(c, PAPER, 0.42 + 0.12 * (idx % 2));
}
function draw() {
clearStage();
for (let n = 0; n < nodes.length; n += 1) {
const node = nodes[n];
const a0 = mapA(node.f0);
const a1 = mapA(node.f1);
if (a1 - a0 < 0.002) {
continue;
}
const idx = node.parent ? node.parent.kids.indexOf(node) : 0;
const on = pick >= 0 && focus.kids[pick] === node;
// 보고 있는 갈래의 윗대는 한 바퀴를 다 덮으니 어둡게 깐다
const above = node.depth <= focus.depth;
let c = colorOf(node, idx);
if (above) {
c = mix(c, BG, 0.55);
}
if (on) {
c = mix(c, PAPER, 0.3);
}
ctx.fillStyle = rgba(c, 1);
wedgePath(ctx, CX, CY, ring0(node.depth), ring0(node.depth) + RING_WIDTH - 2, a0, a1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
if (node.depth === focus.depth + 1 && a1 - a0 > 0.32) {
const mid = (a0 + a1) / 2;
const rr = ring0(node.depth) + RING_WIDTH / 2 - 1;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 10px sans-serif';
ctx.fillStyle = rgba(INK, 0.92);
ctx.fillText(node.depth === 1 ? node.code : node.code.slice(2), CX + dirX(mid) * rr, CY + dirY(mid) * rr);
}
}
// 가운데 — 지금 보는 갈래. 누르면 한 단계 위로
ctx.fillStyle = rgba(PANEL, 1);
ctx.beginPath();
ctx.arc(CX, CY, HOLE_R - 3, 0, TAU);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 11px sans-serif';
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillText(focus === tree ? '100%' : focus.code, CX, CY);
for (let i = 0; i < tree.kids.length; i += 1) {
const k = tree.kids[i];
legendRow(i, LABELS[i], pctText(k.f1 - k.f0), PAL[i], focus !== tree && focus.top === i, false);
}
statRow(190, '보는 갈래', focus === tree ? '100%' : nameOf(focus) + ' · ' + pctText(focus.f1 - focus.f0));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function goUp() {
if (focus.parent) {
drill(focus.parent);
}
}
// 포인터 자리의 마디 — 거리로 고리 겹을, atan2 각을 비율로 되돌려 그 겹의 마디를 찾는다
function hitNode(x, y) {
const d = Math.hypot(x - CX, y - CY);
const depth = 1 + Math.floor((d - HOLE_R) / RING_WIDTH);
if (d < HOLE_R || depth > LEVELS) {
return null;
}
const f = v0 + (angleAt(x, y, CX, CY) / TAU) * (v1 - v0);
for (let n = 0; n < nodes.length; n += 1) {
const node = nodes[n];
if (node.depth === depth && f >= node.f0 && f < node.f1) {
return node;
}
}
return null;
}
function open(node) {
if (node.kids.length > 0 && node !== focus) {
drill(node);
}
else if (node.parent) {
if (node.parent !== focus) {
drill(node.parent);
}
pick = node.parent.kids.indexOf(node);
}
}
function movePick(d) {
const n = focus.kids.length;
if (n === 0) {
return;
}
pick = pick < 0 ? (d > 0 ? 0 : n - 1) : (pick + d + n) % n;
after();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) < HOLE_R) {
goUp();
after();
return;
}
const node = hitNode(p[0], p[1]);
if (node) {
open(node);
}
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
if (pick >= 0 && pick < focus.kids.length && focus.kids[pick].kids.length > 0) {
drill(focus.kids[pick]);
}
after();
}
else if (ev.key === 'Escape' || ev.key === 'Backspace') {
ev.preventDefault();
wake();
goUp();
after();
}
});
if (upBtn) {
upBtn.addEventListener('click', function () { wake(); goUp(); after(); });
}
if (homeBtn) {
homeBtn.addEventListener('click', function () { wake(); drill(tree); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieSunburstDrillRing - vanilla 판(07-pie-chart-sunburst-drill-ring) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieSunburstDrillRing.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieSunburstDrillRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 고리 한 겹의 두께(px)
const RING_WIDTH = 34;
// 갈래를 펼치거나 되돌리는 시간
const DRILL_MS = 650;
// 나무의 깊이 — 묶음 · 하위 묶음 · 낱개, 고리 세 겹
const LEVELS = 3;
const HOLE_R = 28;
const SEED = 467;
const CX = 316;
const CY = 150;
const DEMO_LOOP = 2000;
const TXT_ROOT = '전체 — 고리를 눌러 갈래 펼치기';
const upBtn = root.querySelector('[data-role="up"]') as HTMLButtonElement | null;
const homeBtn = root.querySelector('[data-role="home"]') as HTMLButtonElement | null;
type TreeNode = { code: string; top: number; depth: number; value: number; f0: number; f1: number; parent: TreeNode | null; kids: TreeNode[] };
// 가상 나무 — 맨 위 묶음 넷, 그 아래 둘이나 셋씩, 맨 끝 낱개 값은 시드로
function buildTree(): TreeNode {
const rnd = makeRng(SEED);
const top: TreeNode = { code: '', top: -1, depth: 0, value: 0, f0: 0, f1: 1, parent: null, kids: [] };
function grow(n: TreeNode): void {
if (n.depth >= LEVELS) { n.value = 4 + Math.floor(rnd() * 17); return; }
const k = n.depth === 0 ? 4 : 2 + Math.floor(rnd() * 2);
for (let i = 0; i < k; i += 1) {
const code = n.depth === 0 ? String.fromCharCode(65 + i) : n.code + '-' + String(i + 1);
const c: TreeNode = { code: code, top: n.depth === 0 ? i : n.top, depth: n.depth + 1, value: 0, f0: 0, f1: 0, parent: n, kids: [] };
n.kids.push(c);
grow(c);
n.value += c.value;
}
}
// 부모의 범위 [f0, f1] 를 자식 값의 비율로 다시 나눈다 — 바깥 고리가 안쪽 조각 안에 들어맞는 까닭
function split(n: TreeNode): void {
let acc = n.f0;
for (let i = 0; i < n.kids.length; i += 1) {
const c = n.kids[i];
c.f0 = acc;
acc += ((n.f1 - n.f0) * c.value) / n.value;
c.f1 = acc;
split(c);
}
}
grow(top);
split(top);
return top;
}
const tree = buildTree();
const nodes: TreeNode[] = [];
(function collect(n: TreeNode): void {
for (let i = 0; i < n.kids.length; i += 1) { nodes.push(n.kids[i]); collect(n.kids[i]); }
})(tree);
let focus: TreeNode = tree;
let v0 = 0;
let v1 = 1;
let from0 = 0;
let from1 = 1;
let drillMs = DRILL_MS;
let pick = -1;
// 비율 → 각 — 보고 있는 범위 [v0, v1] 를 0 ~ 2π 로 늘인다. 범위 밖은 양 끝에 붙어 폭 0 이 된다
function mapA(f: number): number { return clamp((f - v0) / (v1 - v0), 0, 1) * TAU; }
function nameOf(n: TreeNode): string { return n.depth === 1 ? LABELS[n.top] : n.code; }
function ring0(d: number): number { return HOLE_R + (d - 1) * RING_WIDTH; }
function report(): void {
if (pick >= 0 && pick < focus.kids.length) {
const k = focus.kids[pick];
say(nameOf(k) + ' · ' + pctText(k.f1 - k.f0));
return;
}
if (focus === tree) { say(TXT_ROOT); } else { say(nameOf(focus) + ' · ' + pctText(focus.f1 - focus.f0)); }
}
function drill(n: TreeNode): void {
from0 = v0;
from1 = v1;
focus = n;
drillMs = 0;
pick = -1;
}
function step(): void {
if (drillMs >= DRILL_MS) { return; }
drillMs = Math.min(DRILL_MS, drillMs + FIXED_DT);
const e = easeInOutPow(drillMs / DRILL_MS, 3);
v0 = lerp(from0, focus.f0, e);
v1 = lerp(from1, focus.f1, e);
}
function settle(): void {
drillMs = DRILL_MS;
v0 = focus.f0;
v1 = focus.f1;
}
function biggest(n: TreeNode): TreeNode {
let b = n.kids[0];
for (let i = 1; i < n.kids.length; i += 1) { if (n.kids[i].value > b.value) { b = n.kids[i]; } }
return b;
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 1100], function (k: number): void {
drill(k === 0 ? biggest(tree) : tree);
report();
});
}
function colorOf(n: TreeNode, idx: number): RGB {
const c = PAL[n.top];
if (n.depth === 1) { return c; }
if (n.depth === 2) { return mix(c, idx % 2 === 0 ? PAPER : BG, idx % 2 === 0 ? 0.22 : 0.12); }
return mix(c, PAPER, 0.42 + 0.12 * (idx % 2));
}
function draw(): void {
clearStage();
for (let n = 0; n < nodes.length; n += 1) {
const node = nodes[n];
const a0 = mapA(node.f0);
const a1 = mapA(node.f1);
if (a1 - a0 < 0.002) { continue; }
const idx = node.parent ? node.parent.kids.indexOf(node) : 0;
const on = pick >= 0 && focus.kids[pick] === node;
// 보고 있는 갈래의 윗대는 한 바퀴를 다 덮으니 어둡게 깐다
const above = node.depth <= focus.depth;
let c = colorOf(node, idx);
if (above) { c = mix(c, BG, 0.55); }
if (on) { c = mix(c, PAPER, 0.3); }
ctx.fillStyle = rgba(c, 1);
wedgePath(ctx, CX, CY, ring0(node.depth), ring0(node.depth) + RING_WIDTH - 2, a0, a1);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
if (node.depth === focus.depth + 1 && a1 - a0 > 0.32) {
const mid = (a0 + a1) / 2;
const rr = ring0(node.depth) + RING_WIDTH / 2 - 1;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 10px sans-serif';
ctx.fillStyle = rgba(INK, 0.92);
ctx.fillText(node.depth === 1 ? node.code : node.code.slice(2), CX + dirX(mid) * rr, CY + dirY(mid) * rr);
}
}
// 가운데 — 지금 보는 갈래. 누르면 한 단계 위로
ctx.fillStyle = rgba(PANEL, 1);
ctx.beginPath();
ctx.arc(CX, CY, HOLE_R - 3, 0, TAU);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 11px sans-serif';
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillText(focus === tree ? '100%' : focus.code, CX, CY);
for (let i = 0; i < tree.kids.length; i += 1) {
const k = tree.kids[i];
legendRow(i, LABELS[i], pctText(k.f1 - k.f0), PAL[i], focus !== tree && focus.top === i, false);
}
statRow(190, '보는 갈래', focus === tree ? '100%' : nameOf(focus) + ' · ' + pctText(focus.f1 - focus.f0));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function goUp(): void {
if (focus.parent) { drill(focus.parent); }
}
// 포인터 자리의 마디 — 거리로 고리 겹을, atan2 각을 비율로 되돌려 그 겹의 마디를 찾는다
function hitNode(x: number, y: number): TreeNode | null {
const d = Math.hypot(x - CX, y - CY);
const depth = 1 + Math.floor((d - HOLE_R) / RING_WIDTH);
if (d < HOLE_R || depth > LEVELS) { return null; }
const f = v0 + (angleAt(x, y, CX, CY) / TAU) * (v1 - v0);
for (let n = 0; n < nodes.length; n += 1) {
const node = nodes[n];
if (node.depth === depth && f >= node.f0 && f < node.f1) { return node; }
}
return null;
}
function open(node: TreeNode): void {
if (node.kids.length > 0 && node !== focus) { drill(node); }
else if (node.parent) {
if (node.parent !== focus) { drill(node.parent); }
pick = node.parent.kids.indexOf(node);
}
}
function movePick(d: number): void {
const n = focus.kids.length;
if (n === 0) { return; }
pick = pick < 0 ? (d > 0 ? 0 : n - 1) : (pick + d + n) % n;
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
if (Math.hypot(p[0] - CX, p[1] - CY) < HOLE_R) { goUp(); after(); return; }
const node = hitNode(p[0], p[1]);
if (node) { open(node); }
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
if (pick >= 0 && pick < focus.kids.length && focus.kids[pick].kids.length > 0) { drill(focus.kids[pick]); }
after();
}
else if (ev.key === 'Escape' || ev.key === 'Backspace') { ev.preventDefault(); wake(); goUp(); after(); }
});
if (upBtn) { upBtn.addEventListener('click', function (): void { wake(); goUp(); after(); }); }
if (homeBtn) { homeBtn.addEventListener('click', function (): void { wake(); drill(tree); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="안쪽 고리와 바깥 고리로 나뉜 선버스트. 고리를 누르거나 Enter 로 갈래를 한 바퀴로 펼치고 Esc 로 한 단계 위로 간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">전체 — 고리를 눌러 갈래 펼치기</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="up" type="button">한 단계 위로</button>
<button className={cx("pc__btn")} data-role="home" type="button">처음으로</button>
</div>
</div>
);
}07 · 안쪽 원과 바깥 고리로 나뉘고 누르면 그 갈래가 한 바퀴로 펼쳐지는 선버스트 — 측정 누적면적 25.982% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function split(n) {
let acc = n.f0;
for (let i = 0; i < n.kids.length; i += 1) {
const c = n.kids[i];
c.f0 = acc;
acc += ((n.f1 - n.f0) * c.value) / n.value;
c.f1 = acc;
split(c);
}
}
let v0 = 0;
let v1 = 1;
let from0 = 0;
let from1 = 1;
let drillMs = DRILL_MS;
let pick = -1;
// 비율 → 각 — 보고 있는 범위 [v0, v1] 를 0 ~ 2π 로 늘인다. 범위 밖은 양 끝에 붙어 폭 0 이 된다
function mapA(f) { return clamp((f - v0) / (v1 - v0), 0, 1) * TAU; }
function nameOf(n) { return n.depth === 1 ? LABELS[n.top] : n.code; }
function ring0(d) { return HOLE_R + (d - 1) * RING_WIDTH; }
08가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 전환
열기를 누르면 안쪽 반지름 비율이 감쇠비 0.408 스프링으로 0 에서 0.55 를 향해 움직여, 256ms 에 0.676 까지 넘쳤다가 656ms 에 자리를 잡는다. 가운데 합계는 구멍이 열리는 순간부터 900ms 동안 0 에서 1,786 까지 easeOutCubic 으로 세고, 조각을 고르면 그 조각 값으로 다시 센다. KPI 합계를 가운데 크게 두는 카드에 맞고, 렌더 수치는 누적면적 22.9%·강도 103.2·22/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 원형 그래프. Enter 로 구멍을 여닫고 ←/→ 로 조각을 고른다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">원형 — 구멍 닫힘</p>
<div class="pc__tools">
<button class="pc__btn" data-role="hole" type="button" aria-pressed="false">도넛으로</button>
<button class="pc__btn" data-role="data" type="button">새 데이터</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
function springStep(s, k, d, dt) {
const acc = -k * (s.x - s.to) - d * s.v;
s.v += acc * dt;
s.x += s.v * dt;
}
// 구멍이 열렸을 때 안쪽 반지름 ÷ 바깥 반지름
const HOLE_RATIO = 0.55;
// 구멍 스프링의 당기는 힘(1/s²) — 끄는 힘 HOLE_DAMP 와 함께 감쇠비가 정해진다
const HOLE_SPRING = 150;
const HOLE_DAMP = 10;
// 가운데 숫자를 세어 올리는 시간
const COUNT_MS = 900;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_PIE = '원형 — 구멍 닫힘';
const TXT_DONUT = '도넛 — 가운데 합계를 세는 중';
const holeBtn = root.querySelector('[data-role="hole"]');
const dataBtn = root.querySelector('[data-role="data"]');
let seed = SEED;
let slices = layout(seededValues(seed, SLICE_N, 120, 480));
const hole = { x: 0, v: 0, to: 0 };
let open = false;
let pick = -1;
let shown = 0;
let countFrom = 0;
let countTo = 0;
let countMs = COUNT_MS;
function total() {
let t = 0;
for (let i = 0; i < slices.length; i += 1) {
t += slices[i].value;
}
return t;
}
// 숫자는 지금 보이는 값에서 새 목표까지, 같은 시간 동안 끝이 느려지는 이징으로
function countToward(v) {
countFrom = shown;
countTo = v;
countMs = 0;
}
function target() { return pick >= 0 ? slices[pick].value : total(); }
function report() {
if (pick >= 0) {
sayPick(pick, slices[pick].share);
return;
}
say(open ? TXT_DONUT : TXT_PIE);
}
function setOpen(on) {
open = on;
hole.to = on ? HOLE_RATIO : 0;
if (on) {
shown = 0;
countToward(target());
}
if (holeBtn) {
holeBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
}
function step() {
springStep(hole, HOLE_SPRING, HOLE_DAMP, FIXED_DT / 1000);
if (countMs < COUNT_MS) {
countMs = Math.min(COUNT_MS, countMs + FIXED_DT);
shown = lerp(countFrom, countTo, easeOutCubic(countMs / COUNT_MS));
}
}
function settle() {
hole.x = hole.to;
hole.v = 0;
countMs = COUNT_MS;
shown = countTo;
}
function biggest() {
let b = 0;
for (let i = 1; i < slices.length; i += 1) {
if (slices[i].value > slices[b].value) {
b = i;
}
}
return b;
}
function demoTick(t) {
runSchedule(t, DEMO_LOOP, [200, 950, 1450], function (k) {
if (k === 0) {
pick = -1;
setOpen(true);
}
else if (k === 1) {
pick = biggest();
countToward(target());
}
else {
pick = -1;
setOpen(false);
}
report();
});
}
function draw() {
clearStage();
// 스프링이 0 아래로 넘치면 구멍이 없는 원으로 둔다
const ratio = Math.max(0, hole.x);
const r0 = R * ratio;
// 구멍이 열릴수록 조각 사이 틈도 벌어진다
const gap = 0.02 * clamp(ratio / HOLE_RATIO, 0, 1.3);
for (let i = 0; i < slices.length; i += 1) {
const s = slices[i];
const r1 = R + (i === pick ? 6 : 0);
ctx.fillStyle = rgba(pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : PAL[i], 1);
wedgePath(ctx, CX, CY, r0, r1, s.a0 + gap / 2, s.a1 - gap / 2);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (r0 > 5) {
ctx.strokeStyle = rgba(PAPER, 0.25);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(CX, CY, r0 - 3, 0, TAU);
ctx.stroke();
}
// 가운데 숫자 — 구멍이 글자를 담을 만큼 열려야 나타난다
const a = clamp((ratio - 0.22) / 0.2, 0, 1);
if (a > 0) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, 0.7 * a);
ctx.font = '500 11px sans-serif';
ctx.fillText(pick >= 0 ? LABELS[pick] : '합계', CX, CY - 16);
ctx.fillStyle = rgba(PAPER, a);
ctx.font = '800 24px sans-serif';
ctx.fillText(groupDigits(shown), CX, CY + 6);
}
for (let i = 0; i < slices.length; i += 1) {
legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === pick, false);
}
statRow(212, '구멍 비율', ratio.toFixed(2) + ' / ' + HOLE_RATIO.toFixed(2));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function movePick(d) {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
countToward(target());
after();
}
function newData() {
seed += 1;
slices = layout(seededValues(seed, SLICE_N, 120, 480));
pick = -1;
countToward(target());
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const d = Math.hypot(p[0] - CX, p[1] - CY);
if (d <= R + 8 && d >= R * Math.max(0, hole.x)) {
const i = sliceAt(slices, angleAt(p[0], p[1], CX, CY));
pick = i === pick ? -1 : i;
countToward(target());
}
else {
setOpen(!open);
}
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
setOpen(!open);
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
countToward(target());
after();
}
});
if (holeBtn) {
holeBtn.addEventListener('click', function () { wake(); setOpen(!open); after(); });
}
if (dataBtn) {
dataBtn.addEventListener('click', function () { wake(); newData(); after(); });
}
if (!demo) {
setOpen(true);
settle();
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PieDonutHoleSpringCount - vanilla 판(08-pie-chart-donut-hole-spring-count) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PieDonutHoleSpringCount.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PieDonutHoleSpringCount() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 감쇠 스프링 — x 가 목표 to 로 당겨지고(k, 1/s²) 속도에 비례해 끌린다(d, 1/s). 속도를 먼저 고치는 반암시 오일러
type Spring = { x: number; v: number; to: number };
function springStep(s: Spring, k: number, d: number, dt: number): void {
const acc = -k * (s.x - s.to) - d * s.v;
s.v += acc * dt;
s.x += s.v * dt;
}
// 구멍이 열렸을 때 안쪽 반지름 ÷ 바깥 반지름
const HOLE_RATIO = 0.55;
// 구멍 스프링의 당기는 힘(1/s²) — 끄는 힘 HOLE_DAMP 와 함께 감쇠비가 정해진다
const HOLE_SPRING = 150;
const HOLE_DAMP = 10;
// 가운데 숫자를 세어 올리는 시간
const COUNT_MS = 900;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 112;
const DEMO_LOOP = 2000;
const TXT_PIE = '원형 — 구멍 닫힘';
const TXT_DONUT = '도넛 — 가운데 합계를 세는 중';
const holeBtn = root.querySelector('[data-role="hole"]') as HTMLButtonElement | null;
const dataBtn = root.querySelector('[data-role="data"]') as HTMLButtonElement | null;
let seed = SEED;
let slices: Slice[] = layout(seededValues(seed, SLICE_N, 120, 480));
const hole: Spring = { x: 0, v: 0, to: 0 };
let open = false;
let pick = -1;
let shown = 0;
let countFrom = 0;
let countTo = 0;
let countMs = COUNT_MS;
function total(): number {
let t = 0;
for (let i = 0; i < slices.length; i += 1) { t += slices[i].value; }
return t;
}
// 숫자는 지금 보이는 값에서 새 목표까지, 같은 시간 동안 끝이 느려지는 이징으로
function countToward(v: number): void {
countFrom = shown;
countTo = v;
countMs = 0;
}
function target(): number { return pick >= 0 ? slices[pick].value : total(); }
function report(): void {
if (pick >= 0) { sayPick(pick, slices[pick].share); return; }
say(open ? TXT_DONUT : TXT_PIE);
}
function setOpen(on: boolean): void {
open = on;
hole.to = on ? HOLE_RATIO : 0;
if (on) { shown = 0; countToward(target()); }
if (holeBtn) { holeBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); }
}
function step(): void {
springStep(hole, HOLE_SPRING, HOLE_DAMP, FIXED_DT / 1000);
if (countMs < COUNT_MS) {
countMs = Math.min(COUNT_MS, countMs + FIXED_DT);
shown = lerp(countFrom, countTo, easeOutCubic(countMs / COUNT_MS));
}
}
function settle(): void {
hole.x = hole.to;
hole.v = 0;
countMs = COUNT_MS;
shown = countTo;
}
function biggest(): number {
let b = 0;
for (let i = 1; i < slices.length; i += 1) { if (slices[i].value > slices[b].value) { b = i; } }
return b;
}
function demoTick(t: number): void {
runSchedule(t, DEMO_LOOP, [200, 950, 1450], function (k: number): void {
if (k === 0) { pick = -1; setOpen(true); }
else if (k === 1) { pick = biggest(); countToward(target()); }
else { pick = -1; setOpen(false); }
report();
});
}
function draw(): void {
clearStage();
// 스프링이 0 아래로 넘치면 구멍이 없는 원으로 둔다
const ratio = Math.max(0, hole.x);
const r0 = R * ratio;
// 구멍이 열릴수록 조각 사이 틈도 벌어진다
const gap = 0.02 * clamp(ratio / HOLE_RATIO, 0, 1.3);
for (let i = 0; i < slices.length; i += 1) {
const s = slices[i];
const r1 = R + (i === pick ? 6 : 0);
ctx.fillStyle = rgba(pick >= 0 && i !== pick ? mix(PAL[i], BG, 0.38) : PAL[i], 1);
wedgePath(ctx, CX, CY, r0, r1, s.a0 + gap / 2, s.a1 - gap / 2);
ctx.fill();
ctx.strokeStyle = rgba(BG, 1);
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (r0 > 5) {
ctx.strokeStyle = rgba(PAPER, 0.25);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(CX, CY, r0 - 3, 0, TAU);
ctx.stroke();
}
// 가운데 숫자 — 구멍이 글자를 담을 만큼 열려야 나타난다
const a = clamp((ratio - 0.22) / 0.2, 0, 1);
if (a > 0) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(PAPER, 0.7 * a);
ctx.font = '500 11px sans-serif';
ctx.fillText(pick >= 0 ? LABELS[pick] : '합계', CX, CY - 16);
ctx.fillStyle = rgba(PAPER, a);
ctx.font = '800 24px sans-serif';
ctx.fillText(groupDigits(shown), CX, CY + 6);
}
for (let i = 0; i < slices.length; i += 1) { legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === pick, false); }
statRow(212, '구멍 비율', ratio.toFixed(2) + ' / ' + HOLE_RATIO.toFixed(2));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function movePick(d: number): void {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
countToward(target());
after();
}
function newData(): void {
seed += 1;
slices = layout(seededValues(seed, SLICE_N, 120, 480));
pick = -1;
countToward(target());
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const d = Math.hypot(p[0] - CX, p[1] - CY);
if (d <= R + 8 && d >= R * Math.max(0, hole.x)) {
const i = sliceAt(slices, angleAt(p[0], p[1], CX, CY));
pick = i === pick ? -1 : i;
countToward(target());
} else {
setOpen(!open);
}
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); wake(); setOpen(!open); after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; countToward(target()); after(); }
});
if (holeBtn) { holeBtn.addEventListener('click', function (): void { wake(); setOpen(!open); after(); }); }
if (dataBtn) { dataBtn.addEventListener('click', function (): void { wake(); newData(); after(); }); }
if (!demo) { setOpen(true); settle(); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 원형 그래프. Enter 로 구멍을 여닫고 ←/→ 로 조각을 고른다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">원형 — 구멍 닫힘</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="hole" type="button" aria-pressed="false">도넛으로</button>
<button className={cx("pc__btn")} data-role="data" type="button">새 데이터</button>
</div>
</div>
);
}08 · 가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 전환 — 측정 누적면적 22.868% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function setOpen(on) {
open = on;
hole.to = on ? HOLE_RATIO : 0;
if (on) {
shown = 0;
countToward(target());
}
if (holeBtn) {
holeBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
}
09포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프 장면
평평한 색으로 그린 조각의 바탕색을 getImageData 로 한 번 읽고, 가장자리 거리와 반지름에서 화소마다 높이(가장자리 9px 의 둥근 턱에 가운데로 갈수록 14px 부푸는 돔)와 법선을 구워 두고, 프레임마다 248×248 판의 61,504 화소를 돌며 그중 조각 위 42,142 화소에서만 포인터 위 120px 높이에 둔 빛으로 확산과 광택을 다시 계산한다. 광택 지수 28 에서는 법선과 반벡터가 12.7° 벌어지면 하이라이트가 절반으로 떨어지고, 결 노이즈 ±8 이 파스텔 면에 고운 질감을 얹는다. 랜딩 페이지의 지표 장면처럼 그래프를 장식으로도 쓰는 곳에 맞고, 렌더 수치는 누적면적 22.0%·강도 26.6·23/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프. 포인터를 움직이거나 ←/→ 로 조각을 골라 빛을 옮긴다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">포인터를 빛 삼아 조각을 비추는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="orbit" type="button" aria-pressed="false">빛 돌리기</button>
<button class="pc__btn" data-role="grain" type="button">결 다시 뿌리기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [46, 38, 62];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x, p) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed, n, lo, hi) {
const rnd = makeRng(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(lo + Math.floor(rnd() * (hi - lo + 1)));
}
return out;
}
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values) {
let total = 0;
for (let i = 0; i < values.length; i += 1) {
total += values[i];
}
const out = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a) { return Math.cos(TOP + a); }
function dirY(a) { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x, y, cx, cy) {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) {
a += TAU;
}
while (a >= TAU) {
a -= TAU;
}
return a;
}
function sliceAt(slices, a) {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) {
return i;
}
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c, cx, cy, r0, r1, a0, a1) {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) {
c.arc(cx, cy, r0, TOP + a1, TOP + a0, true);
}
else {
c.lineTo(cx, cy);
}
c.closePath();
}
function pctText(share) { return (share * 100).toFixed(1) + '%'; }
function sayPick(i, share) { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n) {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row, label, value, c, on, dim) {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t, loop, marks, fire) {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) {
schedCycle = cyc;
schedIdx = 0;
}
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 빛의 높이(px) — 판 위 몇 px 에 떠 있나. 낮을수록 비스듬히 들어 밝고 어두운 차가 커진다
const LIGHT_HEIGHT = 120;
// 광택 지수 — 법선과 반벡터의 내적을 이만큼 거듭제곱한다. 클수록 반짝이는 점이 작고 날카롭다
const GLOSS_POW = 28;
// 결의 세기 — 시드 노이즈를 밝기에 ± 이만큼 더한다
const GRAIN_AMT = 8;
// 판 전체가 가운데로 부푸는 높이와 조각 가장자리가 둥글게 깎이는 폭(px)
const DOME_PX = 14;
const BEVEL_PX = 9;
const SLICE_GAP = 2.4;
const AMBIENT = 0.62;
const SPEC_K = 0.8;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 118;
// 빛 계산 판 한 변 — 장면 1배 좌표에서 계산하고 화면에는 drawImage 로 올린다(배율이 2 여도 화소 수가 늘지 않는다)
const BS = 248;
const BX0 = CX - BS / 2;
const BY0 = CY - BS / 2;
const ORBIT_RX = 100;
const ORBIT_RY = 72;
const DEMO_LOOP = 2000;
const TXT_LIGHT = '포인터를 빛 삼아 조각을 비추는 중';
const orbitBtn = root.querySelector('[data-role="orbit"]');
const grainBtn = root.querySelector('[data-role="grain"]');
const slices = layout(seededValues(SEED, SLICE_N, 10, 36));
const baseCv = document.createElement('canvas');
const litCv = document.createElement('canvas');
baseCv.width = BS;
baseCv.height = BS;
litCv.width = BS;
litCv.height = BS;
const bctxRaw = baseCv.getContext('2d');
const lctxRaw = litCv.getContext('2d');
if (!bctxRaw || !lctxRaw) {
return;
}
const bctx = bctxRaw;
const lctx = lctxRaw;
const N = BS * BS;
const baseRGB = new Float32Array(N * 3);
const alpha = new Float32Array(N);
const heightMap = new Float32Array(N);
const normals = new Float32Array(N * 3);
const grain = new Float32Array(N);
const sliceId = new Int8Array(N);
const outImg = lctx.createImageData(BS, BS);
let grainSeed = SEED;
let orbit = false;
let orbitMs = 0;
let pick = -1;
let dirty = true;
let toX = CX + ORBIT_RX;
let toY = CY;
let lightX = toX;
let lightY = toY;
// 굽기 1 — 평평한 색으로 조각을 그려 한 번 읽는다(getImageData 는 이 굽는 순간에만), 높이와 법선을 화소마다 저장한다
function bakeSurface() {
bctx.clearRect(0, 0, BS, BS);
for (let i = 0; i < SLICE_N; i += 1) {
bctx.fillStyle = rgba(PAL[i], 1);
wedgePath(bctx, BS / 2, BS / 2, 0, R, slices[i].a0, slices[i].a1);
bctx.fill();
}
const img = bctx.getImageData(0, 0, BS, BS).data;
for (let p = 0; p < N; p += 1) {
baseRGB[p * 3] = img[p * 4];
baseRGB[p * 3 + 1] = img[p * 4 + 1];
baseRGB[p * 3 + 2] = img[p * 4 + 2];
}
// 높이 — 판 전체가 가운데로 부푼 곡면 × 조각마다 가장자리가 둥글게 깎인 턱
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
const px = x + 0.5 - BS / 2;
const py = y + 0.5 - BS / 2;
const r = Math.hypot(px, py);
heightMap[p] = 0;
alpha[p] = 0;
sliceId[p] = -1;
if (r > R) {
continue;
}
const a = angleAt(px, py, 0, 0);
let i = sliceAt(slices, a);
if (i < 0) {
i = SLICE_N - 1;
}
const s = slices[i];
const da = Math.min(a - s.a0, s.a1 - a);
// 가장자리까지 거리 — 바깥 호까지와 두 자른 선까지 중 가까운 쪽
const dRad = da < Math.PI / 2 ? r * Math.sin(da) : r;
const d = Math.min(R - r, dRad) - SLICE_GAP / 2;
alpha[p] = clamp(d + 0.5, 0, 1);
sliceId[p] = i;
const bevel = d >= BEVEL_PX ? 1 : (d <= 0 ? 0 : Math.sqrt(1 - (1 - d / BEVEL_PX) * (1 - d / BEVEL_PX)));
heightMap[p] = bevel * (BEVEL_PX + DOME_PX * (1 - (r / R) * (r / R)));
}
}
// 법선 — 이웃 화소의 높이 차로 기울기를 재 (-∂h/∂x, -∂h/∂y, 1) 을 길이 1 로
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
let gx = 0;
let gy = 0;
if (x > 0 && x < BS - 1 && y > 0 && y < BS - 1) {
gx = (heightMap[p + 1] - heightMap[p - 1]) / 2;
gy = (heightMap[p + BS] - heightMap[p - BS]) / 2;
}
const inv = 1 / Math.sqrt(gx * gx + gy * gy + 1);
normals[p * 3] = -gx * inv;
normals[p * 3 + 1] = -gy * inv;
normals[p * 3 + 2] = inv;
}
}
dirty = true;
}
// 굽기 2 — 결: 6px 격자 값 노이즈(부드러운 얼룩) 70% + 화소마다 시드 난수(잔 알갱이) 30%
function bakeGrain() {
const rnd = makeRng(grainSeed);
const G = Math.ceil(BS / 6) + 2;
const lattice = new Float32Array(G * G);
for (let k = 0; k < G * G; k += 1) {
lattice[k] = rnd() * 2 - 1;
}
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const gx = x / 6;
const gy = y / 6;
const ix = Math.floor(gx);
const iy = Math.floor(gy);
const fx = smooth(gx - ix);
const fy = smooth(gy - iy);
const top = lerp(lattice[iy * G + ix], lattice[iy * G + ix + 1], fx);
const bot = lerp(lattice[(iy + 1) * G + ix], lattice[(iy + 1) * G + ix + 1], fx);
grain[y * BS + x] = lerp(top, bot, fy) * 0.7 + (rnd() * 2 - 1) * 0.3;
}
}
dirty = true;
}
// 화소마다 빛 — 확산 = 법선·빛 방향 내적, 광택 = (법선·반벡터)^GLOSS_POW, 결은 그 위에 더한다
function shade() {
const o = outImg.data;
const lx = lightX - BX0;
const ly = lightY - BY0;
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
const q = p * 4;
const al = alpha[p];
if (al <= 0) {
o[q + 3] = 0;
continue;
}
const nx = normals[p * 3];
const ny = normals[p * 3 + 1];
const nz = normals[p * 3 + 2];
let vx = lx - x;
let vy = ly - y;
let vz = LIGHT_HEIGHT - heightMap[p];
const inv = 1 / Math.sqrt(vx * vx + vy * vy + vz * vz);
vx *= inv;
vy *= inv;
vz *= inv;
const diff = Math.max(0, nx * vx + ny * vy + nz * vz);
// 반벡터 — 빛 방향과 보는 방향(정면 0, 0, 1)의 가운데
const hz = vz + 1;
const hinv = 1 / Math.sqrt(vx * vx + vy * vy + hz * hz);
const nh = Math.max(0, (nx * vx + ny * vy + nz * hz) * hinv);
const spec = Math.pow(nh, GLOSS_POW) * SPEC_K * 255;
// 확산 몫을 1.12 까지 열어 빛을 정면으로 받는 곳은 바탕색보다 조금 밝아진다
const lit = (AMBIENT + (1.12 - AMBIENT) * diff) * (pick >= 0 && sliceId[p] === pick ? 1.12 : 1);
const g = grain[p] * GRAIN_AMT;
o[q] = baseRGB[p * 3] * lit + spec + g;
o[q + 1] = baseRGB[p * 3 + 1] * lit + spec + g;
o[q + 2] = baseRGB[p * 3 + 2] * lit + spec + g;
o[q + 3] = 255 * al;
}
}
lctx.putImageData(outImg, 0, 0);
dirty = false;
}
function orbitTarget(ms) {
const a = (TAU * ms) / DEMO_LOOP;
toX = CX + ORBIT_RX * Math.cos(a);
toY = CY + ORBIT_RY * Math.sin(a);
}
function report() {
if (pick >= 0) {
sayPick(pick, slices[pick].share);
}
else {
say(TXT_LIGHT);
}
}
function step() {
if (orbit) {
orbitMs += FIXED_DT;
orbitTarget(orbitMs);
}
const k = 1 - Math.exp((-12 * FIXED_DT) / 1000);
const nx = lightX + (toX - lightX) * k;
const ny = lightY + (toY - lightY) * k;
if (Math.abs(nx - lightX) + Math.abs(ny - lightY) > 0.05) {
dirty = true;
}
lightX = nx;
lightY = ny;
}
function settle() {
lightX = toX;
lightY = toY;
dirty = true;
}
function demoTick(t) {
if (t < 200) {
return;
}
// 시연은 2초에 한 바퀴 — 빛을 바로 궤도 위에 놓아 루프가 이어진다
orbitTarget(t - 200);
lightX = toX;
lightY = toY;
dirty = true;
}
function draw() {
if (dirty) {
shade();
}
clearStage();
// 바닥 그림자 — 빛 반대쪽으로 조금 밀린 흐린 원
const sx = CX - (lightX - CX) * 0.1;
const sy = CY + 10 - (lightY - CY) * 0.1;
const g = ctx.createRadialGradient(sx, sy, R * 0.6, sx, sy, R + 18);
g.addColorStop(0, 'rgba(8,6,16,0.55)');
g.addColorStop(1, 'rgba(8,6,16,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(sx, sy, R + 18, 0, TAU);
ctx.fill();
ctx.drawImage(litCv, BX0, BY0);
const lg = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, 16);
lg.addColorStop(0, 'rgba(255,248,234,0.95)');
lg.addColorStop(1, 'rgba(255,248,234,0)');
ctx.fillStyle = lg;
ctx.beginPath();
ctx.arc(lightX, lightY, 16, 0, TAU);
ctx.fill();
for (let i = 0; i < SLICE_N; i += 1) {
legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === pick, false);
}
statRow(212, '빛 높이 · 광택', String(LIGHT_HEIGHT) + ' px · ' + String(GLOSS_POW));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function setOrbit(on) {
orbit = on;
if (orbitBtn) {
orbitBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
// 움직임을 줄인 환경에서는 누를 때마다 궤도를 8분의 1 바퀴씩 옮긴다
if (on && still) {
orbitMs += DEMO_LOOP / 8;
orbitTarget(orbitMs);
}
}
function aimAt(i) {
const m = (slices[i].a0 + slices[i].a1) / 2;
toX = CX + dirX(m) * R * 0.55;
toY = CY + dirY(m) * R * 0.55;
}
function movePick(d) {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
setOrbit(false);
aimAt(pick);
dirty = true;
after();
}
bakeSurface();
bakeGrain();
orbitTarget(0);
lightX = toX;
lightY = toY;
cv.addEventListener('pointermove', function (ev) {
wake();
const p = toScene(ev);
setOrbit(false);
toX = p[0];
toY = p[1];
after();
});
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
setOrbit(false);
toX = p[0];
toY = p[1];
pick = Math.hypot(p[0] - CX, p[1] - CY) <= R ? sliceAt(slices, angleAt(p[0], p[1], CX, CY)) : -1;
dirty = true;
after();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
movePick(1);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
wake();
movePick(-1);
}
else if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
wake();
setOrbit(!orbit);
after();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
wake();
pick = -1;
dirty = true;
after();
}
});
if (orbitBtn) {
orbitBtn.addEventListener('click', function () { wake(); setOrbit(!orbit); after(); });
}
if (grainBtn) {
grainBtn.addEventListener('click', function () { wake(); grainSeed += 1; bakeGrain(); after(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// PiePointerLightGrain - vanilla 판(09-pie-chart-pointer-light-grain) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PiePointerLightGrain.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PiePointerLightGrain() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 각도 원점 — 12시 방향에서 시계 방향으로 잰다 (캔버스는 y 가 아래로 커서 양의 각이 시계 방향)
const TOP = -Math.PI / 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [46, 38, 62];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
// 가상 데이터의 이름 — 실제 항목이 아니라 순서만 뜻한다
const LABELS = ['항목 A', '항목 B', '항목 C', '항목 D', '항목 E', '항목 F', '항목 G', '항목 H', '항목 I'];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 앞뒤가 대칭인 거듭제곱 이징 — p 가 클수록 가운데에서 더 빨리 지나간다
function easeInOutPow(x: number, p: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 0.5 * Math.pow(2 * c, p) : 1 - 0.5 * Math.pow(2 - 2 * c, p);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 가상 데이터가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededValues(seed: number, n: number, lo: number, hi: number): number[] {
const rnd = makeRng(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(lo + Math.floor(rnd() * (hi - lo + 1))); }
return out;
}
type Slice = { value: number; share: number; a0: number; a1: number };
// 숫자 목록 → 부채꼴 — 합계로 나눈 몫을 앞에서부터 쌓아 시작 각과 끝 각을 만든다
function layout(values: number[]): Slice[] {
let total = 0;
for (let i = 0; i < values.length; i += 1) { total += values[i]; }
const out: Slice[] = [];
let acc = 0;
for (let i = 0; i < values.length; i += 1) {
const a0 = (acc / total) * TAU;
acc += values[i];
out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
}
return out;
}
function dirX(a: number): number { return Math.cos(TOP + a); }
function dirY(a: number): number { return Math.sin(TOP + a); }
// 포인터가 가리키는 각 — atan2 로 구한 화면 각을 12시 기준 0 ~ 2π 로 옮긴다
function angleAt(x: number, y: number, cx: number, cy: number): number {
let a = Math.atan2(y - cy, x - cx) - TOP;
while (a < 0) { a += TAU; }
while (a >= TAU) { a -= TAU; }
return a;
}
function sliceAt(slices: Slice[], a: number): number {
for (let i = 0; i < slices.length; i += 1) {
if (a >= slices[i].a0 && a < slices[i].a1) { return i; }
}
return -1;
}
// 부채꼴 경로 — 안쪽 반지름 r0 이 0 이면 가운데 점까지, 아니면 도넛 조각
function wedgePath(c: CanvasRenderingContext2D, cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): void {
c.beginPath();
c.arc(cx, cy, r1, TOP + a0, TOP + a1, false);
if (r0 > 0.5) { c.arc(cx, cy, r0, TOP + a1, TOP + a0, true); } else { c.lineTo(cx, cy); }
c.closePath();
}
function pctText(share: number): string { return (share * 100).toFixed(1) + '%'; }
function sayPick(i: number, share: number): void { say(LABELS[i] + ' · ' + pctText(share)); }
// 세 자리마다 쉼표 — 지역 설정에 기대지 않는다
function groupDigits(n: number): string {
const s = String(Math.round(Math.abs(n)));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s[i];
}
return (n < 0 ? '-' : '') + out;
}
// 왼쪽 범례 — 색 칩 + 이름 + 비율, 고른 줄은 밝게
const LEG_X = 16;
const LEG_Y = 100;
const LEG_ROW = 16;
function legendRow(row: number, label: string, value: string, c: RGB, on: boolean, dim: boolean): void {
const y = LEG_Y + row * LEG_ROW;
ctx.fillStyle = rgba(c, dim ? 0.3 : 1);
ctx.fillRect(LEG_X, y - 9, 9, 9);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.font = (on ? '700' : '500') + ' 11px sans-serif';
ctx.fillStyle = rgba(PAPER, on ? 1 : (dim ? 0.4 : 0.74));
ctx.fillText(label, LEG_X + 15, y);
ctx.textAlign = 'right';
ctx.fillText(value, LEG_X + 132, y);
}
// 범례 아래 수치 한 줄 — 이름표 + 값
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 시연 일정 — 루프 안의 시각(ms) 표를 지날 때마다 한 번씩 부른다
let schedCycle = -1;
let schedIdx = 0;
function runSchedule(t: number, loop: number, marks: number[], fire: (k: number, cycle: number) => void): void {
const cyc = Math.floor(t / loop);
const p = t - cyc * loop;
if (cyc !== schedCycle) { schedCycle = cyc; schedIdx = 0; }
while (schedIdx < marks.length && p >= marks[schedIdx]) {
fire(schedIdx, cyc);
schedIdx += 1;
}
}
// 빛의 높이(px) — 판 위 몇 px 에 떠 있나. 낮을수록 비스듬히 들어 밝고 어두운 차가 커진다
const LIGHT_HEIGHT = 120;
// 광택 지수 — 법선과 반벡터의 내적을 이만큼 거듭제곱한다. 클수록 반짝이는 점이 작고 날카롭다
const GLOSS_POW = 28;
// 결의 세기 — 시드 노이즈를 밝기에 ± 이만큼 더한다
const GRAIN_AMT = 8;
// 판 전체가 가운데로 부푸는 높이와 조각 가장자리가 둥글게 깎이는 폭(px)
const DOME_PX = 14;
const BEVEL_PX = 9;
const SLICE_GAP = 2.4;
const AMBIENT = 0.62;
const SPEC_K = 0.8;
const SLICE_N = 6;
const SEED = 467;
const CX = 316;
const CY = 150;
const R = 118;
// 빛 계산 판 한 변 — 장면 1배 좌표에서 계산하고 화면에는 drawImage 로 올린다(배율이 2 여도 화소 수가 늘지 않는다)
const BS = 248;
const BX0 = CX - BS / 2;
const BY0 = CY - BS / 2;
const ORBIT_RX = 100;
const ORBIT_RY = 72;
const DEMO_LOOP = 2000;
const TXT_LIGHT = '포인터를 빛 삼아 조각을 비추는 중';
const orbitBtn = root.querySelector('[data-role="orbit"]') as HTMLButtonElement | null;
const grainBtn = root.querySelector('[data-role="grain"]') as HTMLButtonElement | null;
const slices: Slice[] = layout(seededValues(SEED, SLICE_N, 10, 36));
const baseCv = document.createElement('canvas');
const litCv = document.createElement('canvas');
baseCv.width = BS;
baseCv.height = BS;
litCv.width = BS;
litCv.height = BS;
const bctxRaw = baseCv.getContext('2d');
const lctxRaw = litCv.getContext('2d');
if (!bctxRaw || !lctxRaw) { return; }
const bctx: CanvasRenderingContext2D = bctxRaw;
const lctx: CanvasRenderingContext2D = lctxRaw;
const N = BS * BS;
const baseRGB = new Float32Array(N * 3);
const alpha = new Float32Array(N);
const heightMap = new Float32Array(N);
const normals = new Float32Array(N * 3);
const grain = new Float32Array(N);
const sliceId = new Int8Array(N);
const outImg = lctx.createImageData(BS, BS);
let grainSeed = SEED;
let orbit = false;
let orbitMs = 0;
let pick = -1;
let dirty = true;
let toX = CX + ORBIT_RX;
let toY = CY;
let lightX = toX;
let lightY = toY;
// 굽기 1 — 평평한 색으로 조각을 그려 한 번 읽는다(getImageData 는 이 굽는 순간에만), 높이와 법선을 화소마다 저장한다
function bakeSurface(): void {
bctx.clearRect(0, 0, BS, BS);
for (let i = 0; i < SLICE_N; i += 1) {
bctx.fillStyle = rgba(PAL[i], 1);
wedgePath(bctx, BS / 2, BS / 2, 0, R, slices[i].a0, slices[i].a1);
bctx.fill();
}
const img = bctx.getImageData(0, 0, BS, BS).data;
for (let p = 0; p < N; p += 1) {
baseRGB[p * 3] = img[p * 4];
baseRGB[p * 3 + 1] = img[p * 4 + 1];
baseRGB[p * 3 + 2] = img[p * 4 + 2];
}
// 높이 — 판 전체가 가운데로 부푼 곡면 × 조각마다 가장자리가 둥글게 깎인 턱
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
const px = x + 0.5 - BS / 2;
const py = y + 0.5 - BS / 2;
const r = Math.hypot(px, py);
heightMap[p] = 0;
alpha[p] = 0;
sliceId[p] = -1;
if (r > R) { continue; }
const a = angleAt(px, py, 0, 0);
let i = sliceAt(slices, a);
if (i < 0) { i = SLICE_N - 1; }
const s = slices[i];
const da = Math.min(a - s.a0, s.a1 - a);
// 가장자리까지 거리 — 바깥 호까지와 두 자른 선까지 중 가까운 쪽
const dRad = da < Math.PI / 2 ? r * Math.sin(da) : r;
const d = Math.min(R - r, dRad) - SLICE_GAP / 2;
alpha[p] = clamp(d + 0.5, 0, 1);
sliceId[p] = i;
const bevel = d >= BEVEL_PX ? 1 : (d <= 0 ? 0 : Math.sqrt(1 - (1 - d / BEVEL_PX) * (1 - d / BEVEL_PX)));
heightMap[p] = bevel * (BEVEL_PX + DOME_PX * (1 - (r / R) * (r / R)));
}
}
// 법선 — 이웃 화소의 높이 차로 기울기를 재 (-∂h/∂x, -∂h/∂y, 1) 을 길이 1 로
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
let gx = 0;
let gy = 0;
if (x > 0 && x < BS - 1 && y > 0 && y < BS - 1) {
gx = (heightMap[p + 1] - heightMap[p - 1]) / 2;
gy = (heightMap[p + BS] - heightMap[p - BS]) / 2;
}
const inv = 1 / Math.sqrt(gx * gx + gy * gy + 1);
normals[p * 3] = -gx * inv;
normals[p * 3 + 1] = -gy * inv;
normals[p * 3 + 2] = inv;
}
}
dirty = true;
}
// 굽기 2 — 결: 6px 격자 값 노이즈(부드러운 얼룩) 70% + 화소마다 시드 난수(잔 알갱이) 30%
function bakeGrain(): void {
const rnd = makeRng(grainSeed);
const G = Math.ceil(BS / 6) + 2;
const lattice = new Float32Array(G * G);
for (let k = 0; k < G * G; k += 1) { lattice[k] = rnd() * 2 - 1; }
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const gx = x / 6;
const gy = y / 6;
const ix = Math.floor(gx);
const iy = Math.floor(gy);
const fx = smooth(gx - ix);
const fy = smooth(gy - iy);
const top = lerp(lattice[iy * G + ix], lattice[iy * G + ix + 1], fx);
const bot = lerp(lattice[(iy + 1) * G + ix], lattice[(iy + 1) * G + ix + 1], fx);
grain[y * BS + x] = lerp(top, bot, fy) * 0.7 + (rnd() * 2 - 1) * 0.3;
}
}
dirty = true;
}
// 화소마다 빛 — 확산 = 법선·빛 방향 내적, 광택 = (법선·반벡터)^GLOSS_POW, 결은 그 위에 더한다
function shade(): void {
const o = outImg.data;
const lx = lightX - BX0;
const ly = lightY - BY0;
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
const q = p * 4;
const al = alpha[p];
if (al <= 0) { o[q + 3] = 0; continue; }
const nx = normals[p * 3];
const ny = normals[p * 3 + 1];
const nz = normals[p * 3 + 2];
let vx = lx - x;
let vy = ly - y;
let vz = LIGHT_HEIGHT - heightMap[p];
const inv = 1 / Math.sqrt(vx * vx + vy * vy + vz * vz);
vx *= inv;
vy *= inv;
vz *= inv;
const diff = Math.max(0, nx * vx + ny * vy + nz * vz);
// 반벡터 — 빛 방향과 보는 방향(정면 0, 0, 1)의 가운데
const hz = vz + 1;
const hinv = 1 / Math.sqrt(vx * vx + vy * vy + hz * hz);
const nh = Math.max(0, (nx * vx + ny * vy + nz * hz) * hinv);
const spec = Math.pow(nh, GLOSS_POW) * SPEC_K * 255;
// 확산 몫을 1.12 까지 열어 빛을 정면으로 받는 곳은 바탕색보다 조금 밝아진다
const lit = (AMBIENT + (1.12 - AMBIENT) * diff) * (pick >= 0 && sliceId[p] === pick ? 1.12 : 1);
const g = grain[p] * GRAIN_AMT;
o[q] = baseRGB[p * 3] * lit + spec + g;
o[q + 1] = baseRGB[p * 3 + 1] * lit + spec + g;
o[q + 2] = baseRGB[p * 3 + 2] * lit + spec + g;
o[q + 3] = 255 * al;
}
}
lctx.putImageData(outImg, 0, 0);
dirty = false;
}
function orbitTarget(ms: number): void {
const a = (TAU * ms) / DEMO_LOOP;
toX = CX + ORBIT_RX * Math.cos(a);
toY = CY + ORBIT_RY * Math.sin(a);
}
function report(): void {
if (pick >= 0) { sayPick(pick, slices[pick].share); } else { say(TXT_LIGHT); }
}
function step(): void {
if (orbit) { orbitMs += FIXED_DT; orbitTarget(orbitMs); }
const k = 1 - Math.exp((-12 * FIXED_DT) / 1000);
const nx = lightX + (toX - lightX) * k;
const ny = lightY + (toY - lightY) * k;
if (Math.abs(nx - lightX) + Math.abs(ny - lightY) > 0.05) { dirty = true; }
lightX = nx;
lightY = ny;
}
function settle(): void {
lightX = toX;
lightY = toY;
dirty = true;
}
function demoTick(t: number): void {
if (t < 200) { return; }
// 시연은 2초에 한 바퀴 — 빛을 바로 궤도 위에 놓아 루프가 이어진다
orbitTarget(t - 200);
lightX = toX;
lightY = toY;
dirty = true;
}
function draw(): void {
if (dirty) { shade(); }
clearStage();
// 바닥 그림자 — 빛 반대쪽으로 조금 밀린 흐린 원
const sx = CX - (lightX - CX) * 0.1;
const sy = CY + 10 - (lightY - CY) * 0.1;
const g = ctx.createRadialGradient(sx, sy, R * 0.6, sx, sy, R + 18);
g.addColorStop(0, 'rgba(8,6,16,0.55)');
g.addColorStop(1, 'rgba(8,6,16,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(sx, sy, R + 18, 0, TAU);
ctx.fill();
ctx.drawImage(litCv, BX0, BY0);
const lg = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, 16);
lg.addColorStop(0, 'rgba(255,248,234,0.95)');
lg.addColorStop(1, 'rgba(255,248,234,0)');
ctx.fillStyle = lg;
ctx.beginPath();
ctx.arc(lightX, lightY, 16, 0, TAU);
ctx.fill();
for (let i = 0; i < SLICE_N; i += 1) { legendRow(i, LABELS[i], pctText(slices[i].share), PAL[i], i === pick, false); }
statRow(212, '빛 높이 · 광택', String(LIGHT_HEIGHT) + ' px · ' + String(GLOSS_POW));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function setOrbit(on: boolean): void {
orbit = on;
if (orbitBtn) { orbitBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); }
// 움직임을 줄인 환경에서는 누를 때마다 궤도를 8분의 1 바퀴씩 옮긴다
if (on && still) { orbitMs += DEMO_LOOP / 8; orbitTarget(orbitMs); }
}
function aimAt(i: number): void {
const m = (slices[i].a0 + slices[i].a1) / 2;
toX = CX + dirX(m) * R * 0.55;
toY = CY + dirY(m) * R * 0.55;
}
function movePick(d: number): void {
pick = pick < 0 ? (d > 0 ? 0 : SLICE_N - 1) : (pick + d + SLICE_N) % SLICE_N;
setOrbit(false);
aimAt(pick);
dirty = true;
after();
}
bakeSurface();
bakeGrain();
orbitTarget(0);
lightX = toX;
lightY = toY;
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
setOrbit(false);
toX = p[0];
toY = p[1];
after();
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
setOrbit(false);
toX = p[0];
toY = p[1];
pick = Math.hypot(p[0] - CX, p[1] - CY) <= R ? sliceAt(slices, angleAt(p[0], p[1], CX, CY)) : -1;
dirty = true;
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); movePick(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); wake(); movePick(-1); }
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); wake(); setOrbit(!orbit); after(); }
else if (ev.key === 'Escape') { ev.preventDefault(); wake(); pick = -1; dirty = true; after(); }
});
if (orbitBtn) { orbitBtn.addEventListener('click', function (): void { wake(); setOrbit(!orbit); after(); }); }
if (grainBtn) { grainBtn.addEventListener('click', function (): void { wake(); grainSeed += 1; bakeGrain(); after(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프. 포인터를 움직이거나 ←/→ 로 조각을 골라 빛을 옮긴다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">포인터를 빛 삼아 조각을 비추는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="orbit" type="button" aria-pressed="false">빛 돌리기</button>
<button className={cx("pc__btn")} data-role="grain" type="button">결 다시 뿌리기</button>
</div>
</div>
);
}09 · 포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프 장면 — 측정 누적면적 21.997% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shade() {
const o = outImg.data;
const lx = lightX - BX0;
const ly = lightY - BY0;
for (let y = 0; y < BS; y += 1) {
for (let x = 0; x < BS; x += 1) {
const p = y * BS + x;
const q = p * 4;
const al = alpha[p];
if (al <= 0) {
o[q + 3] = 0;
continue;
}
const nx = normals[p * 3];
const ny = normals[p * 3 + 1];
const nz = normals[p * 3 + 2];
let vx = lx - x;
let vy = ly - y;
let vz = LIGHT_HEIGHT - heightMap[p];
const inv = 1 / Math.sqrt(vx * vx + vy * vy + vz * vz);
vx *= inv;
vy *= inv;
vz *= inv;
const diff = Math.max(0, nx * vx + ny * vy + nz * vz);
// 반벡터 — 빛 방향과 보는 방향(정면 0, 0, 1)의 가운데
const hz = vz + 1;
const hinv = 1 / Math.sqrt(vx * vx + vy * vy + hz * hz);
const nh = Math.max(0, (nx * vx + ny * vy + nz * hz) * hinv);
const spec = Math.pow(nh, GLOSS_POW) * SPEC_K * 255;
// 확산 몫을 1.12 까지 열어 빛을 정면으로 받는 곳은 바탕색보다 조금 밝아진다
const lit = (AMBIENT + (1.12 - AMBIENT) * diff) * (pick >= 0 && sliceId[p] === pick ? 1.12 : 1);
const g = grain[p] * GRAIN_AMT;
o[q] = baseRGB[p * 3] * lit + spec + g;
o[q + 1] = baseRGB[p * 3 + 1] * lit + spec + g;
o[q + 2] = baseRGB[p * 3 + 2] * lit + spec + g;
o[q + 3] = 255 * al;
}
}
lctx.putImageData(outImg, 0, 0);
dirty = false;
}
조각이 아주 얇을 때·라벨이 한쪽에 몰릴 때·화면이 좁을 때 원형 그래프가 깨지는 곳
여기 적은 내용은 소스의 함수를 직접 불러 잰 값이거나 소스의 식으로 다시 계산한 값이고, 기종별 프레임 소요 시간은 측정 범위 밖이다.
얇은 조각의 비율 글자는 다 펴진 뒤에만 쓴다
01 이 펴지는 중에 비율을 쓰면 작은 조각에서 글자가 부채꼴 밖으로 삐져나오므로, 진행률이 1 이 된 6% 이상 조각에만 글자를 그린다. 시드 467 에서 5.9% 인 셋째 조각에 글자가 없는 이유다. 시차도 한계가 있어서 (조각 수 − 1) × 80ms 가 900ms 를 넘으면 첫 조각이 다 자란 뒤에 마지막 조각이 출발해 펴지는 흐름이 끊겨 보이고, 지금은 480ms 라 일곱 조각이 겹쳐 자란다.
튀어나온 조각의 바깥 끝에서 hover 가 떨린다
판정 반지름을 원래 반지름 R 로만 두면 튀어나온 조각의 바깥 끝(R + x)을 가리킬 때 hover 가 풀렸다 다시 걸리며 조각이 떨린다. 02 의 hitSlice 는 R + x + 4 까지 보아 이 떨림을 막는다. 감쇠비를 1 이상으로 올리면 넘침 없이 느리게 다가가 튕김이 사라지고, 0.2 아래로 내리면 여러 번 출렁여 그래프 전체가 흔들려 보인다.
라벨이 범위 끝에 몰리면 밀기가 느려진다
맨 위 경계에 붙은 라벨은 더 올라가지 못하고 아래쪽 이웃만 반씩 내려가므로 수렴이 느리다. 05 의 첫 시안은 작은 조각을 왼쪽 위에 두었다가 밀기를 다 돌고도 겹침이 남아, 작은 조각을 오른쪽 위로 옮겼다. 반씩 밀기는 간격에 한없이 다가가기만 해서 정확히 16 을 조건으로 두면 상한 40번까지 다 돌기 때문에 0.25px 여유를 둔다.
데이터 순서로 칠하면 뒤 조각 옆면이 앞으로 올라온다
03 은 조각을 가운데 각의 사인 순서로 세워 화면 뒤쪽부터 칠한다. 데이터 순서대로 칠하면 뒤쪽 조각의 옆면이 앞 조각 위에 그려진다. 다만 한 조각이 180° 를 넘으면 가운데 각 하나로 정한 순서가 옆면 일부에서 틀릴 수 있는데, 이 데이터의 가장 큰 조각은 26.3% 라 그 경우가 없다.
같은 자리가 아니라 같은 항목끼리 짝짓는다
04 에서 기간마다 남은 조각을 배열 순서로 짝지으면 색이 다른 항목으로 넘어가 비율이 엉뚱한 쪽으로 흐른다. 시작 각과 폭을 따로 보간해도 이웃 사이에 틈이나 겹침이 생기므로, 두 경계를 보간해 이웃이 경계를 함께 쓰게 한다. 전환 도중에 다시 누를 때 목표 기간의 첫 각에서 출발하면 화면이 튀어서, goPeriod 는 지금 보이는 각에서 출발한다.
점 수와 도는 방향이 다르면 중간 모양이 꼬인다
06 의 두 테두리는 같은 OUTLINE_SAMPLES 로 뽑아야 점끼리 짝이 맞는다. 한쪽만 거꾸로 돌면 점들이 서로 엇갈려 지나가며 중간 모양이 꼬이고, 점을 12개쯤으로 줄이면 큰 조각의 호가 각진 다각형으로 보인다. 지금의 48점에서 호 점 사이 간격은 가장 넓은 곳이 4.2° 다.
늘인 각을 자르지 않으면 한 바퀴를 넘는다
07 에서 보는 범위를 넓힐 때 범위 밖 조각의 각을 자르지 않으면 2π 를 넘어 한 바퀴 넘게 겹쳐 그려진다. mapA 의 clamp 가 그 조각들을 양 끝에 폭 0 으로 붙인다. 겹 수를 4 로 늘리면 바깥 고리 반지름이 28 + 4 × 34 = 164px 가 되어 지름이 무대 높이 300 을 넘는다.
닫히는 스프링이 0 아래로 넘치면 arc 가 예외를 던진다
08 의 구멍이 닫힐 때 스프링은 0 아래로도 넘친다. 음수 반지름을 arc 에 넘기면 예외가 나 프레임 루프가 멈추는데, 첫 시안에서 실제로 멈췄다. 그래서 안쪽 반지름은 R × max(0, x) 로 자르고 구멍 테두리 선은 5px 이상일 때만 긋는다. 숫자 세기는 900ms, 구멍은 656ms 에 자리를 잡아 둘의 시간이 다르다는 점도 함께 기억해 둔다.
화소를 매 프레임 읽으면 루프가 밀린다
09 가 프레임마다 getImageData 로 바탕색을 다시 읽으면 헤드리스 렌더나 저사양 기기에서 requestAnimationFrame 이 밀린다. 바탕색은 굽는 순간 한 번만 읽어 배열에 두고, 빛 계산도 배율 2 의 실제 화소가 아니라 1배 판에서 해 일을 4분의 1로 줄인 뒤 늘려 그린다. 그 대가로 배율 2 화면에서는 조금 부드럽게 보인다. 결도 세게 줄수록 파스텔 면이 얼룩져 탁해지므로, 첫 시안보다 낮춘 지금의 GRAIN_AMT 8 로 두었다.
좁은 화면에서도 누르는 자리는 맞는다
무대 폭은 min(92vw, calc(160vh − 12.8vw)) 에 480:300 비율이라 화면이 좁아지면 캔버스가 통째로 줄어든다. 내부 좌표는 480×300 그대로 두고 getBoundingClientRect 비례로 포인터를 장면 좌표로 바꿔 판정하므로, 작아진 원에서도 고르는 조각이 어긋나지 않는다. 실제 화소는 기기 배율을 곱하되 2 에서 자르고, 폭 340px 이하에서는 버튼과 상태 줄 글자를 10px 로 줄인다.
장면을 내 숫자와 스프링 값으로 바꿔 직접 돌려 볼 때 압축을 여는 비밀번호는 yukp4mxx 이다. 장면 폴더마다 바닐라 소스가 들어 있고, 같은 계산을 옮긴 리액트 판도 함께 묶여 있다.
튀어나오는 거리·기울기 각도·구멍 크기를 바꿔 보는 변형과 색만으로 읽지 않게 하는 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 튀어나오는 거리 늘리기 | 02 EXPLODE_DIST 16 → 30 | 반지름 112px 대비 14.3% 에서 26.8% 로 멀어져 조각이 원에서 확실히 빠져나온다. 넘침 11.3%·최고점 272ms 는 그대로이고 넘치는 거리만 1.82px 에서 3.40px 가 된다. |
| 기울기 각 키우기 | 03 TILT_DEG 55 → 70 | 세로 비율이 0.574 에서 0.342 로 납작해져 세로 반지름이 64.2px 에서 38.3px 로 준다. 보이는 옆면은 21.3px 에서 24.4px 로 조금 늘 뿐인데 옆면/세로 반지름이 0.332 에서 0.638 이 되어 원판이 두 배 가까이 두꺼워 보인다. |
| 도넛 구멍 넓히기 | 08 HOLE_RATIO 0.55 → 0.75 | 고리 두께가 반지름의 45% 에서 25% 로, 칠한 넓이가 69.75% 에서 43.75% 로 줄어 가운데 숫자 자리가 넓어진다. 넘침은 22.9%·256ms 로 같고, 최고점에서는 안쪽이 103.2px 까지 열려 고리가 8.8px 만 남는다. |
조각의 구분을 색에만 맡기지 않는다. 조각을 고르면 상태 줄이 「항목 B · 27.7%」 처럼 이름과 비율을 글자로 읽어 주고(07 은 「B-2 · 11.9%」 처럼 하위 갈래 코드), 고른 조각은 02 에서는 튀어나온 거리, 03 에서는 10px 들림, 08 에서는 가운데 숫자로도 드러난다. 01 은 다 펴진 6% 이상 조각에 비율을 직접 써 둔다.
마우스 없이도 아홉 장면을 다 다룰 수 있다. 캔버스마다 tabindex="0" 과 한국어 aria-label 이 있어 포커스가 들어가고, ←/→ 로 조각을 옮겨 고르는 것과 Escape 로 고른 것을 푸는 것은 아홉 장면 공통이다(07 은 Escape·Backspace 가 한 단계 위로). 진짜 <button> 은 04 가 기간 1·2·3 세 개, 나머지가 두 개씩이고, 04 의 기간 버튼과 02·06·08·09 의 켜고 끄는 버튼은 aria-pressed 로 상태를 알린다. 03 은 ↑/↓ 로 기울기를 5° 씩 바꾼다.
상태 줄은 자동 시연 동안 aria-live="off" 로 말이 없다가, 사람이 처음 포인터나 키, 버튼을 건드리는 순간 polite 로 바뀌어 그 뒤의 선택만 읽는다. prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 프레임 루프를 시작하지 않고 settle() 로 끝 상태를 만든 뒤 draw() 를 한 번 부른다. 이 설정은 페이지를 열 때 한 번만 읽으므로, 보는 도중에 바꾸면 새로 고친 뒤에 반영된다. 입력은 이 상태에서도 받고 누를 때마다 after() 가 끝 상태까지 계산해 다시 그리며, 01 과 05 의 Enter 만은 끝 상태로 바로 가는 동작이라 앞 화면과 같은 그림이 남는다.
원형 그래프를 CSS 원뿔 그라디언트만으로 그리면 어디서 막히는가?
멈춰 있는 원형 그래프 한 장은 CSS 만으로도 그릴 수 있다. conic-gradient 는 각도 구간마다 색을 나눠 칠하므로, 비율을 누적한 각을 색 정지점으로 넣으면 layout 이 만드는 것과 같은 부채꼴이 배경 한 장으로 나온다. 다만 이 편의 아홉 장면은 CSS 가 아니라 캔버스와 자바스크립트 계산으로 만들었다. 배경 한 장에서는 조각 하나만 떼어 옮기기 어려워 02 의 튀어나옴, 03 의 옆면, 06 의 막대 변환을 그대로 옮길 수 없고, 어느 조각을 가리켰는지 알아내는 데에도 angleAt 같은 atan2 계산이 따로 필요하다. 05 의 라벨 밀기, 08 의 숫자 세기, 09 의 화소 조명도 반복 계산이라 스크립트의 몫이다.
같은 데이터 시각화 줄기의 다른 차트는 SVG 로 그리는 그래프 9종, 캔버스 차트 애니메이션 9종, 계산으로 그리는 데이터 시각화 차트 9종 에 있다. 값 하나로 그래프를 다시 그리는 쪽은 그래프 애니메이션 피커, 화소 계산으로 장면을 만드는 쪽은 캔버스 특수효과 9종 이 가깝다.
원형 그래프를 내 대시보드에 붙일 때 자주 나오는 물음
파이 차트 만들기에 차트 라이브러리가 꼭 필요한가?
필요하지 않다. 이 편의 아홉 장면은 라이브러리나 CDN, WebGL 없이 캔버스 2D 의 arc 와 경로 채우기, requestAnimationFrame 을 바탕으로 그리고, 화소 조명을 하는 09 만 getImageData·putImageData 를 더 쓴다. 각은 layout 의 누적 나눗셈, 고르기는 angleAt 의 atan2 하나로 끝나서, 축이나 범례가 따로 필요 없는 원형 그래프라면 이 두 함수가 출발점이 된다.
내 숫자를 넣으려면 어디를 바꾸나?
01·02·03·08·09 는 layout(seededValues(시드, 개수, 최소, 최대)) 한 줄에서 가상 값을 만들므로, 그 자리에 내 숫자 배열을 넘기면 된다. 조각 수가 바뀌면 SLICE_N 을 함께 맞추고, 9개를 넘기면 이름 배열 LABELS 도 늘린다. 04 는 기간별 배열을 만드는 buildSets, 05 는 makeValues, 06 은 setData 안의 seededValues 줄, 07 은 묶음 나무를 만드는 buildTree 가 그 자리다.
화면 읽기 프로그램에서는 조각 비율이 어떻게 들리나?
캔버스에 포커스를 두고 ←/→ 로 조각을 옮기면 상태 줄이 고른 조각의 이름과 비율을 한 줄로 읽는다. 자동 시연이 도는 동안에는 읽지 않다가 처음 키를 누른 뒤부터 읽기 시작하므로, 시연의 움직임마다 소리가 쏟아지지 않는다.








