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

버블 차트 9종 — 면적 비례 반지름부터 겹침 풀기와 묶음 중력까지

버블 차트는 자료 한 줄을 어떻게 원 하나로 바꿀까 — 01번은 반지름을 값의 제곱근으로 잡아 넓이가 값에 맞게 하고, 02번은 겹친 원을 되풀이해 밀어 풀며, 09번은 묶음 안의 묶음으로 확대해 들어간다. 원의 위치와 크기는 계산 함수가 정하고 캔버스는 그 결과만 칠하는 데모다.

버블 차트는 점을 찍는 그림이 아니라 가로·세로 위치와 면적 세 값을 하나의 원으로 합치는 계산이다 — 버블 차트 만들기는 어느 값을 위치로, 어느 값을 넓이로 보낼지 정하는 데서 시작한다

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

이 편의 아홉 장면은 거품 구조를 하나로 맞춘다. 거품은 { x, y, r, v, g } 다섯 칸짜리 객체이고, 겹침을 푸는 함수가 x·y 를 바꾸고 반지름 함수가 r 을 정하며 화면은 그 객체 목록과 장면 시계만 읽어 그린다.

값 세 개를 어디에 보내느냐가 차트의 첫 결정이다

가로 값과 세로 값은 눈금을 거쳐 좌표가 되고 크기 값은 반지름이 된다. 크기 값에는 축이 없어서 03 번은 대표 원 세 개로 범례를 따로 그린다.

시계와 난수를 장면 안에서 고정한다

장면은 Date 나 Math.random 을 한 번도 부르지 않는다. 진행은 장면 시간표의 경과 시간으로, 흩뿌리는 자리는 시드를 받는 seeded 생성기로 정해서 같은 장면은 같은 거품 배치를 만든다. 이름과 값은 가상의 항목이다.

버블 차트 9종 — 면적에 비례하는 반지름·겹침을 반복해서 푸는 배치·세 값의 눈금과 범례·포인터에 밀려나는 거품·값이 바뀔 때 스프링 이동·라벨 들어맞춤·시점 사이 이동 꼬리·묶음 중력·묶음 속 확대

순서는 크기에서 시작해 이웃 관계, 시간, 묶음으로 간다. 01~03 은 반지름과 배치와 눈금, 04·05 는 포인터와 값 갱신, 06 은 라벨, 07 은 시점 사이 이동, 08·09 는 묶음과 계층이다.

렌더로 잰 아홉 장면의 면적·프레임·강도

장면 누적면적 움직인 프레임 강도 평균
01 면적 반지름 11.0% 23/23 44.3
02 겹침 풀기 1.7% 20/23 50.6
03 세 값 눈금 29.9% 19/23 50.0
04 포인터 반발 17.1% 23/23 50.7
05 스프링 재배치 9.0% 23/23 38.4
06 라벨 들어맞춤 14.0% 19/23 41.7
07 이동 꼬리 14.9% 23/23 58.6
08 묶음 중력 37.2% 23/23 28.5
09 묶음 속 확대 43.4% 19/23 35.5

누적면적은 09 가 43.4% 로 가장 넓고 02 가 1.7% 로 가장 좁으며, 강도 평균은 07 이 58.6 로 가장 높고 08 이 28.5 로 가장 낮다. 01·04·05·07·08 은 23/23 이라 매 프레임 움직였고 03·06·09 는 19/23 이다.

01면적에 비례하는 반지름

크기를 읽는 사람이 값을 속지 않게 하려면 반지름이 아니라 넓이가 값을 따라야 한다. radiusOf 는 값 20 에서 반지름 18 을 잡고 값의 제곱근에 비례시켜 R_MIN 6 과 R_MAX 46 사이로 누르며, 오른쪽의 radiusLinear 는 일부러 반지름을 값에 그대로 비례시킨 대비판이다. 같은 장면의 누적면적은 11.0% 이고 23/23 프레임에서 움직임이 잡혔다.

면적에 비례하는 반지름
새 창에서 열기

01 · 면적에 비례하는 반지름 — 측정 누적면적 11.014% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

R_MINR_MAXradiusOf
    // 반지름 18 은 값 20 에서 출발한다 — 두 판이 같은 기준 거품에서 갈라진다
    const R_REF = 18;
    const V_REF = 20;
    const VALS = [36, 30, 25, 20, 16, 13, 11, 9];
    const GROW_I = 3;
    const LAP_S = 9.6;
    const START_S = 1.6;
    const REST_S = 5.2;
    // 넓이 기준 — 반지름은 값의 제곱근에 비례한다
    function radiusOf(v) { return clamp(R_REF * Math.sqrt(v / V_REF), R_MIN, R_MAX); }
    // 대비판 — 반지름이 값에 그대로 비례한다
    function radiusLinear(v) { return clamp(R_REF * v / V_REF, R_MIN, R_MAX); }

02겹침을 반복해서 밀어 푸는 배치

위치가 같은 자료에서도 거품이 서로 가리지 않게 하려면 겹침을 한 라운드에 PACK_ITER 번씩 되풀이해 민다. 두 원의 중심 거리가 반지름 합보다 작으면 겹친 만큼을 반대편 반지름 비율로 나눠 서로 밀어내므로 작은 원이 더 많이 움직이고, 라운드마다 남은 겹침 합을 오른쪽 선에 찍는다. 누적면적은 1.7% 로 가장 작고 20/23 프레임에서 움직임이 잡혔다.

겹침을 반복해서 밀어 푸는 배치
새 창에서 열기

02 · 겹침을 반복해서 밀어 푸는 배치 — 측정 누적면적 1.684% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PACK_ITERoverlapOfrelax
    function doRound() {
        for (let p = 0; p < PACK_ITER; p += 1) {
            relaxPass(bs, 0);
            keepIn(bs, PANEL_L);
        }
        relaxPass(bsSlow, 0);
        keepIn(bsSlow, PANEL_L);
        hist.push(overlapOf(bs));
        histSlow.push(overlapOf(bsSlow));
        round += 1;
    }

03가로·세로·크기 세 값의 눈금

세 변수를 한 장에 올리면 축 끝이 어중간한 수일 때 눈금이 읽히지 않는다. niceStep 은 범위를 TICK_TARGET 5 로 나눈 값을 1·2·5 에 10 의 거듭제곱을 곱한 간격으로 고르고, 크기 범례는 최댓값을 넘지 않는 가장 큰 1·2·5 계열 수와 그 절반·5분의 1 로 원 세 개를 그린다. 누적면적은 29.9% 이고 19/23 프레임에서 움직임이 잡혔다.

가로·세로·크기 세 값의 눈금
새 창에서 열기

03 · 가로·세로·크기 세 값의 눈금 — 측정 누적면적 29.922% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

niceStepTICK_TARGETlegendSizes
    function niceStep(range, target) {
        const raw = range / target;
        const p = Math.pow(10, Math.floor(Math.log10(raw)));
        const f = raw / p;
        const m = f <= 1.5 ? 1 : (f <= 3.5 ? 2 : (f <= 7.5 ? 5 : 10));
        return m * p;
    }
    function niceAxis(lo, hi) {
        const step = niceStep(hi - lo, TICK_TARGET);
        return { a: Math.floor(lo / step + 1e-9) * step, b: Math.ceil(hi / step - 1e-9) * step, step: step };
    }
    // 크기 범례 — 최댓값을 넘지 않는 가장 큰 1·2·5 × 10 의 거듭제곱을 맨 위로 두고 그 절반과 5분의 1 을 더한다
    function floorNice(v) {
        const p = Math.pow(10, Math.floor(Math.log10(v)));
        const f = v / p;
        return (f >= 5 ? 5 : (f >= 2 ? 2 : 1)) * p;
    }
    function legendSizes(vmax) {
        const top = floorNice(vmax);
        return [top, top / 2, top / 5];
    }

04포인터 둘레로 밀려나는 거품

마우스를 올려 거품 하나를 읽게 하는 호버는 이웃이 비켜나야 하는데, 이 장면은 시간표가 움직이는 가짜 포인터로 그 모습을 대신 보인다. falloff 는 거리 0 에서 1, REPEL_R 64 에서 0 으로 닿는 곡선이라 경계에서 거품이 튀지 않고, 밀린 목표를 FOLLOW 로 따라가다 DAMP 0.78 로 속도가 줄며 제자리로 돌아온다. 누적면적은 17.1% 이고 23/23 프레임에서 움직임이 잡혔다.

포인터 둘레로 밀려나는 거품
새 창에서 열기

04 · 포인터 둘레로 밀려나는 거품 — 측정 누적면적 17.131% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REPEL_RfalloffDAMP
    // 힘의 세기 — 거리 d 가 0 이면 1, 경계 REPEL_R 이면 0, 그 사이는 매끈한 곡선(가장자리에서 기울기도 0)
    function falloff(d) {
        if (d >= REPEL_R) {
            return 0;
        }
        const u = 1 - d / REPEL_R;
        return u * u * (3 - 2 * u);
    }

05값이 바뀌면 스프링으로 크기를 옮기기

실시간 수치가 갱신될 때 반지름이 목표로 곧장 건너뛰면 화면이 튄다. 왼쪽은 목표 반지름과의 차이에 SPRING_K 0.08 을 곱해 속도에 더하고 SPRING_DAMP 0.8 을 곱해 한 번 넘쳤다 가라앉으며, 목표는 R_K 4.2 에 값의 제곱근을 곱한 값이고 커진 거품은 걸음마다 겹침을 한 번씩 밀어 이웃을 비킨다. 값이 바뀌는 네 번에서 누적면적은 9.0% 이고 23/23 프레임에서 움직임이 잡혔다.

값이 바뀌면 스프링으로 크기를 옮기기
새 창에서 열기

05 · 값이 바뀌면 스프링으로 크기를 옮기기 — 측정 누적면적 9.016% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_KtargetR_K
    // 고정 간격 한 걸음 — 왼쪽은 힘(목표 차 × SPRING_K)을 속도에 더하고 SPRING_DAMP 를 곱한 뒤 반지름에 더한다
    function step() {
        for (let i = 0; i < N; i += 1) {
            velR[i] = (velR[i] + (target(i) - ballsL[i].r) * SPRING_K) * SPRING_DAMP;
            ballsL[i].r += velR[i];
            ballsR[i].r = target(i);
        }
        relax(ballsL, PANEL_L, 1, 1.5, 0.012);
        relax(ballsR, PANEL_R, 1, 1.5, 0.012);
    }

06라벨이 원 안에 들어가는지 재서 나누기

작은 거품에도 이름이 읽히려면 글자 폭과 원 안쪽 폭을 비교해 놓을 자리를 정해야 한다. chordW 는 글자 높이 절반만큼 벗어난 현의 길이에서 LABEL_PAD 3 을 양쪽으로 뺀 값이고, planOf 는 큰 글자, 작은 글자, 앞 몇 자만, 원 바깥 표시 순서로 시도한다. 누적면적은 14.0% 이고 19/23 프레임에서 움직임이 잡혔다.

라벨이 원 안에 들어가는지 재서 나누기
새 창에서 열기

06 · 라벨이 원 안에 들어가는지 재서 나누기 — 측정 누적면적 13.956% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LABEL_PADglyphWchordW
    // 거품 안쪽 폭 — 글자 높이 절반만큼 아래위로 벗어난 현의 길이에서 좌우 여백을 뺀다
    function chordW(r, px) { return 2 * Math.sqrt(Math.max(0, r * r - (px * 0.55) * (px * 0.55))) - 2 * LABEL_PAD; }
    function planOf(name, r) {
        for (const px of [FONT_BIG, FONT_SMALL]) {
            if (glyphW(name, px) <= chordW(r, px)) {
                return { mode: 0, px: px, s: name };
            }
        }
        // 앞 몇 자만 — 들어가는 가장 긴 접두(2자 이상)
        for (let n = name.length - 1; n >= 2; n -= 1) {
            const s = name.slice(0, n);
            if (glyphW(s, FONT_SMALL) <= chordW(r, FONT_SMALL)) {
                return { mode: 1, px: FONT_SMALL, s: s };
            }
        }
        return { mode: 2, px: FONT_SMALL, s: name };
    }

07시점 사이를 이어 그리는 이동 꼬리

연도마다 주어진 가로·세로·크기를 곡선으로 이어야 거품이 시점 사이에서 미끄러진다. splineStep 은 앞 점과 시작 점과 끝 점과 뒤 점 네 개로 두 시점 사이를 3차 곡선으로 잇고, yearAt 은 0.5초에서 7.6초까지 시점 위치를 한 번 부드럽게 옮기며, 지나온 자리는 TRAIL_LEN 16 개의 점으로 남는다. 누적면적은 14.9% 이고 23/23 프레임에서 움직임이 잡혔다.

시점 사이를 이어 그리는 이동 꼬리
새 창에서 열기

07 · 시점 사이를 이어 그리는 이동 꼬리 — 측정 누적면적 14.902% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TRAIL_LENsplineStepyearAt
    // 네 점(앞 · 시작 · 끝 · 뒤)을 지나 두 시점 사이를 잇는 3차 곡선 — pos 는 0 부터 KEYS - 1 까지의 소수 위치
    function splineStep(a, pos) {
        const i = Math.min(Math.floor(pos), a.length - 2);
        const u = pos - i;
        const p0 = a[Math.max(i - 1, 0)];
        const p1 = a[i];
        const p2 = a[i + 1];
        const p3 = a[Math.min(i + 2, a.length - 1)];
        return 0.5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u);
    }

08묶음 중심으로 끌어당기는 중력

분류별로 모아 보이려면 거품마다 속한 묶음의 중심으로 끄는 힘이 필요하다. step 은 중심까지의 벡터에 PULL 0.02 를 곱해 속도에 더하고 DAMP 0.82 를 곱해 옮긴 뒤 겹침을 한 번 밀어 균형을 맞추며, 묶음 사이의 거리는 setCenters 로 중심 좌표 한 벌만 바꿔 벌린다. 누적면적은 37.2% 이고 23/23 프레임에서 움직임이 잡혔다.

묶음 중심으로 끌어당기는 중력
새 창에서 열기

08 · 묶음 중심으로 끌어당기는 중력 — 측정 누적면적 37.159% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PULLcentersDAMP
    // 고정 간격 한 걸음 — 속도에 (중심까지 벡터 × PULL) 을 더하고 DAMP 를 곱해 옮긴 뒤, 겹침을 한 번 밀어 균형을 맞춘다
    function step() {
        for (let i = 0; i < bs.length; i += 1) {
            const c = centers[bs[i].g];
            vel[i][0] = (vel[i][0] + (c[0] - bs[i].x) * PULL) * DAMP;
            vel[i][1] = (vel[i][1] + (c[1] - bs[i].y) * PULL) * DAMP;
            bs[i].x += vel[i][0];
            bs[i].y += vel[i][1];
        }
        relaxPass(bs, 1);
        keepIn(bs, PANEL_ONE);
    }

09묶음 안에 묶음을 담는 확대

큰 분류에서 세부 항목으로 내려가려면 바깥 원 안에 작은 원을 같은 겹침 풀기로 채우고 바깥 반지름을 따로 구해야 한다. encloseR 는 안쪽 원의 중심을 가장 바깥으로 삐져나간 원 쪽으로 80 번 조금씩 옮겨 모든 안쪽 원을 감싸는 근사 반지름을 돌려주고(정확한 최소 외접원은 아니다), 묶음을 고르면 보는 상자가 ZOOM_EASE 3.2 로 접근하며 sX 가 좌표를 확대 비율로 바꾼다. 누적면적은 43.4% 로 가장 넓고 19/23 프레임에서 움직임이 잡혔다.

묶음 안에 묶음을 담는 확대
새 창에서 열기

09 · 묶음 안에 묶음을 담는 확대 — 측정 누적면적 43.374% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ZOOM_EASEencloseRsX
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        const k = 1 - Math.exp(-ZOOM_EASE * dt);
        const tx = zg < 0 ? CX0 : grps[zg].x;
        const ty = zg < 0 ? CY0 : grps[zg].y;
        const ts = zg < 0 ? 1 : 98 / (grps[zg].R + PAD_G);
        vcx += (tx - vcx) * k;
        vcy += (ty - vcy) * k;
        vs += (ts - vs) * k;
    }
    function sX(x) { return (x - vcx) * vs + CX0; }

버블 차트를 코드로 그리면 한 줄의 자료는 어떤 값 몇 개로 원 하나가 될까?

거품 하나는 값 다섯 칸이다

한 줄의 자료는 위치 두 값과 크기 값 하나를 받아 { x, y, r, v, g } 가 된다. 아래 다섯 칸을 장면마다 같은 이름으로 쓰기 때문에 겹침을 푸는 함수와 라벨 함수가 서로의 장면에서도 그대로 돌아간다.

칸 뜻 쓰는 함수
x·y 원의 중심 좌표 relaxPass·keepIn
r 반지름. 크기 값의 제곱근에서 나온다 relaxPass·chordW
v 크기 값 자체. 반지름과 범례의 원본이다 radiusOf·legendSizes
g 속한 묶음 번호 step·setCenters

자료를 배열로 적고 눈금은 계산으로 만든다

03 과 07 의 자료는 xs·ys·ss 세 배열로 적고, 눈금 간격과 축 끝은 niceAxis 가 최솟값과 최댓값에서 계산한다. 자료만 바꾸면 눈금과 원 위치가 한 번에 따라오므로 축 숫자를 손으로 맞출 일이 없다.

자료 한 줄에서 위치 두 값을 눈금으로 옮기고, 크기 값을 제곱근으로 반지름에 바꾸고, 겹치면 서로 밀어 푸는 — 버블 차트 원 하나가 그려지는 순서

눈금을 먼저 정하고 반지름을 그다음에 구한다

niceAxis 가 가로·세로 축 끝을 정해야 값이 좌표가 되고, radiusOf 가 크기 값을 반지름으로 바꾼다. 이 둘이 끝나야 거품 목록이 완성되고 겹침 풀기가 시작된다.

겹침 풀기는 라운드마다 줄어든다

PACK_ITER 가 4 이면 세 라운드를 지나서야 남은 겹침 합이 0.6 까지 내려가고, 열두 라운드 뒤에는 0 이 된다. 02 번의 점선은 라운드마다 한 번만 미는 경우라서 오른쪽 선에서 두 곡선이 갈라지는 모습을 볼 수 있다.

위치가 정해진 뒤에 글자를 얹는다

planOf 는 위치와 반지름이 끝난 거품에만 이름을 정한다. 값이 바뀌어 반지름이 변하면 같은 순서로 라벨 모양이 다시 정해진다.

버블 차트 예시처럼 실제 화면에 붙일 때 — 라이브러리 없이 만들기·자료를 배열로 적는 법·값이 갱신되는 시점

어디에 붙이나

대시보드에서 항목을 비교할 때, 포트폴리오 비중을 보일 때, 설문 결과를 응답 수로 보일 때 원의 크기가 곧 이야기가 된다. 버블 차트 사이트에서 본 그림을 직접 짜 보려는 사람에게는 02 의 겹침 풀기와 06 의 라벨 규칙이 가장 먼저 필요하다.

갱신은 값 하나만 바꾸면 된다

05 는 vals[i] 하나만 바꾸고 나머지는 목표 반지름 함수와 스프링이 이어받는다. 실제 화면에서도 새 숫자가 들어올 때 그 거품의 값만 바꾸면 되고, 거품 목록을 새로 만들 필요가 없다.

트리맵과는 무엇이 다른가

트리맵 차트 편은 칸의 넓이를 사각형으로 나누는 배치이고, 이 편은 원이 서로 밀어내며 자리를 찾는 배치라서 위치 자체가 계산의 결과라는 점이 다르다.

라이브러리 없이 그리기

버블 차트 그리기에 필요한 것은 캔버스 2D 와 이 편의 함수들이다. 아홉 장면은 외부 스크립트나 WebGL 을 불러오지 않고 한 파일 안에서 돈다.

버블 차트가 깨지는 순간 — 반지름을 값에 그대로 비례시켜 넓이가 제곱으로 부풀어 보이는 함정·겹침 풀기를 끝내지 못하고 원이 떨리는 경우·긴 라벨이 작은 원 밖으로 넘치는 일

반지름을 값에 그대로 비례시키면

값이 20 에서 40 으로 두 배가 될 때 제곱근 판은 반지름이 18 에서 25.5 로 늘어 넓이가 두 배이지만, 선형 판은 반지름이 18 에서 36 이 되어 넓이가 네 배로 읽힌다. 01 번의 오른쪽 대비판이 이 차이를 같은 값으로 나란히 보여 준다.

겹침 풀기가 끝나지 않으면

PACK_ITER 가 4 인 채로는 세 라운드가 지나도 겹침 합이 0.6 남는다. 08 에서 PULL 을 0.08 로 올리면 묶음으로 끄는 힘과 겹침을 미는 힘이 부딪혀 겹침 합이 87.1, 평균 속도가 6.81 로 커져 묶음이 불안정해진다.

긴 라벨이 작은 원 밖으로 넘치면

글자 크기를 고정하고 폭을 재지 않으면 이름이 이웃 원 위로 넘친다. 06 은 chordW 로 안쪽 폭을 재서 모양을 정하고, 그래도 안 들어가는 원은 선을 빼 바깥에 쓴다.

아홉 장면의 소스가 든 zip 은 아래 카드에서 받는다. 압축은 pwwb8ndg 으로 풀면 열린다.

반복 횟수와 스프링 세기와 중력 세기를 바꾸면 느낌은 얼마나 달라질까 — PACK_ITER·SPRING_K·PULL 을 바꿔 본 차이

이름 바꾼 값 느낌
겹침 반복을 늘리기 02 PACK_ITER 4 → 24 첫 라운드 뒤 남는 겹침 합이 39.1 에서 0.0 으로 줄어 한 라운드에 다 퍼진다. 4 번이면 세 라운드 뒤 0.6 이다
스프링을 단단하게 05 SPRING_K 0.08 → 0.25 변화의 90% 에 닿는 시간이 117ms 에서 67ms 로 줄지만 넘침이 변화량의 23.8% 에서 47.9% 로 커진다
중력을 세게 08 PULL 0.02 → 0.08 가장 큰 묶음 반지름이 61 에서 55 로 촘촘해지지만 겹침 합이 33.4 에서 87.1, 평균 속도가 2.54 에서 6.81 이 된다

값을 바꾸기 전에 볼 것

SPRING_K 를 올리면 닿는 시간은 줄고 넘침은 늘어난다. 5% 안에 정착하는 시간도 467ms 에서 383ms 로 줄지만 이웃을 미는 폭이 커지므로, 넘침이 거슬리면 SPRING_DAMP 쪽을 함께 본다.

PULL 은 묶음이 모이는 속도와 겹침을 미는 힘의 다툼이다. 위 수치는 묶음 중심을 벌린 뒤 3.2초 시점의 값이라서 시점을 바꾸면 달라질 수 있다.

움직임 줄이기에서는 멈춘 한 장의 버블 차트 — 장면 이름과 가장 큰 거품의 값을 문장으로 읽어 주는 접근성

움직임 줄이기와 탭 숨김

아홉 소스 모두 prefers-reduced-motion 설정을 읽어 켜져 있으면 한 장으로 멈춘 버블 차트를 그린다. 탭이 숨으면 멈췄다가 돌아오면 시계를 다시 맞추고, 숨은 동안의 몇 초가 한 프레임에 몰리지 않게 한다.

키보드와 읽어 주는 문장

무대 상자에 포커스를 두면 방향키가 거품을 고르고 Enter·Space 가 누르며, P 는 일시정지, N 은 한 걸음, R 은 다시다. 캔버스는 장면을 설명하는 aria-label 의 role="img" 이고, 한 판이 끝나면 숨은 role="status" 가 가장 큰 거품의 이름과 값을 문장으로 알린다.

상태 줄은 처음 손댈 때 켜진다

상태 줄은 시연 중에는 aria-live="off" 이고 처음 손대면 polite 로 바뀐다. 시연만 보는 사람에게 말이 쏟아지지 않게 하려는 장치다.

버블 차트 FAQ — 라이브러리 없이 만들기·크기는 반지름인지 면적인지·웹에 올릴 때 필요한 것

버블 차트 만들기에 라이브러리가 필요한가요

캔버스 2D 만 있으면 되고 이 편의 아홉 장면은 라이브러리·WebGL·CDN 을 부르지 않는다. 겹침 풀기·눈금·라벨 규칙이 모두 몇 줄짜리 순수 함수라서 필요한 부분만 잘라 붙여도 돈다.

크기는 반지름인가요 면적인가요

면적이 값을 따라야 한다. 반지름을 값의 제곱근에 비례시키면 값이 두 배일 때 넓이가 두 배가 되고, 반지름을 값에 그대로 비례시키면 넓이가 네 배로 읽힌다.

웹에 올릴 때 무엇을 더 챙기나요

캔버스에 role="img" 와 설명 문장을 달고, 움직임 줄이기 설정에서는 멈춘 한 장을 그리게 하며, 방향키와 일시정지 키를 받는 것을 권한다. 값만 배열로 바꾸면 눈금·반지름·라벨이 따라오므로 자료 연결은 마지막에 해도 된다.

참고로 MDN 의 requestAnimationFrame 문서는 그리기 루프를, Page Visibility API 문서는 탭이 숨는 때를, KeyboardEvent 문서는 방향키 처리를, prefers-reduced-motion 문서는 움직임 줄이기 설정을 다룬다. 같은 계산 방식의 차트는 파이 차트 편과 막대 차트 편에서, 원 사이의 힘으로 자리를 잡는 그림은 포스 그래프 편에서 볼 수 있다.

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

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