GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

원형 그래프 만들기 9종 — 조각이 튀어나오고 3D로 기우는 계산

원형 그래프는 다 그려진 뒤의 움직임에서 읽히는 정도가 갈린다 — 02번은 마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오고, 03번은 원판을 기울여 옆면을 뒤에서부터 칠하며, 06번은 원이 풀려 막대 그래프로 바뀐다.

가상 데이터로 그린 원형 그래프 아홉 개, 조각을 직접 건드려 보기

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

원형 그래프 만들기 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 프레임이다.

조각마다 시차를 두고 시계 방향으로 쓸며 그려지는 원형 그래프
새 창에서 열기

01 · 조각마다 시차를 두고 시계 방향으로 쓸며 그려지는 원형 그래프 — 측정 누적면적 22.602% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWEEP_MSSLICE_STAGGERDATA_SEED
    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 프레임이다.

마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오는 원형 그래프
새 창에서 열기

02 · 마우스를 올린 조각이 가운데 선을 따라 스프링처럼 튀어나오는 원형 그래프 — 측정 누적면적 25.572% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EXPLODE_DISTSPRING_KSPRING_DAMP
    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 프레임이다.

원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트
새 창에서 열기

03 · 원판을 기울여 옆면 두께까지 그리는 3차원 원형 차트 — 측정 누적면적 19.733% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILT_DEGDEPTH_PXSIDE_SHADE
    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 프레임이다.

기간을 바꾸면 조각 각도가 이어지며 새 비율로 바뀌는 원형 그래프
새 창에서 열기

04 · 기간을 바꾸면 조각 각도가 이어지며 새 비율로 바뀌는 원형 그래프 — 측정 누적면적 23.678% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MORPH_MSDATA_SETSEASE_POW
    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 프레임이다.

바깥 라벨이 지시선을 달고 겹치면 위아래로 밀려나는 원형 그래프
새 창에서 열기

05 · 바깥 라벨이 지시선을 달고 겹치면 위아래로 밀려나는 원형 그래프 — 측정 누적면적 5.303% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LABEL_GAPRELAX_ITERSELBOW_LEN
    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 프레임이다.

원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환
새 창에서 열기

06 · 원이 풀리며 같은 조각들이 막대 그래프로 늘어서는 전환 — 측정 누적면적 26.331% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

UNROLL_MSBAR_GAPOUTLINE_SAMPLES
    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 프레임이다.

안쪽 원과 바깥 고리로 나뉘고 누르면 그 갈래가 한 바퀴로 펼쳐지는 선버스트
새 창에서 열기

07 · 안쪽 원과 바깥 고리로 나뉘고 누르면 그 갈래가 한 바퀴로 펼쳐지는 선버스트 — 측정 누적면적 25.982% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_WIDTHDRILL_MSLEVELS
        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 프레임이다.

가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 전환
새 창에서 열기

08 · 가운데 구멍이 스프링으로 열려 도넛 차트가 되고 합계 숫자가 차오르는 전환 — 측정 누적면적 22.868% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HOLE_RATIOHOLE_SPRINGCOUNT_MS
    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 프레임이다.

포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프 장면
새 창에서 열기

09 · 포인터를 빛으로 삼아 조각마다 밝기와 광택이 바뀌는 원형 그래프 장면 — 측정 누적면적 21.997% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIGHT_HEIGHTGLOSS_POWGRAIN_AMT
    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 가 그 자리다.

화면 읽기 프로그램에서는 조각 비율이 어떻게 들리나?

캔버스에 포커스를 두고 ←/→ 로 조각을 옮기면 상태 줄이 고른 조각의 이름과 비율을 한 줄로 읽는다. 자동 시연이 도는 동안에는 읽지 않다가 처음 키를 누른 뒤부터 읽기 시작하므로, 시연의 움직임마다 소리가 쏟아지지 않는다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.