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

막대 그래프 만들기 9종 — 누적 막대 그래프 만들기까지 움직이는 판

데이터가 바뀌었는데 막대가 순간이동하면 무엇이 늘었는지 눈이 놓친다 — 그래서 01번은 막대를 스프링으로 키워 목표를 한 번 넘었다 돌아오게 하고, 02번은 순위가 뒤집힐 때 막대가 줄을 바꿔 달리며, 07번은 기둥 옆면 명암을 빛 방향으로 계산한다.

가상 매출 숫자로 막대 아홉 개를 키우고 달리게 해 보는 판

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

막대 높이로 쓴 숫자의 출처

막대 높이는 모두 makeRng(seed) 라는 32비트 정수 섞기 함수에서 뽑은 가상 값이다. 시드를 470 과 4702~4709 로 고정해 두어서 페이지를 다시 열어도 같은 막대가 같은 높이로 솟고, 이름표도 「항목 A」~「항목 J」, 06 의 #1~#60, 07 의 A1~D4, 08 의 구간 번호처럼 자리 표시뿐이다. 무작위 함수와 현재 시각을 읽는 코드가 아홉 파일에 하나도 없으니, 이 글의 밀리초와 픽셀 수치는 같은 순서로 버튼을 누르면 그대로 다시 나온다.

보고서와 대시보드에서 아홉 판이 맡는 자리

매출 대시보드를 처음 열 때 숫자가 올라오는 첫 그래프는 01, 월별 순위가 뒤바뀌는 흐름을 보여 줄 때는 02 가 맞다. 채널별 유입처럼 한 막대 안에 몫이 여럿인 숫자라면 누적 막대 그래프 만들기의 요점은 03 처럼 아래 층의 합을 위층의 바닥으로 넘기는 일이고, 설문 문항 이름이 길어 세로로 세우기 어려우면 04 의 가로 막대가 자리를 덜 먹는다.

값이 실시간으로 커지는 방문자 수는 05 의 눈금 다시 잡기, 일 단위 기록이 빽빽한 연간 그래프는 06 의 어안 확대가 받는다. 첫 화면에서 지역별 수치를 입체로 보여 주는 07, 응답 시간이 어디에 몰리는지 설명하는 08, 전월 대비 오르내림을 한 장에 요약하는 09 까지가 이 편이 잡은 쓰임이다.

막대 그래프 만들기 9종 — 스프링 성장·순위 경주·누적 층·가로 라벨·눈금 다시 잡기·어안 확대·등축 기둥·입자 히스토그램·증감 기준선

판 순서는 막대가 처음 솟는 순간(01)에서 시작해 값이 시간에 따라 바뀌고 쌓이는 장면(02·03), 글자와 눈금이 막대에 맞춰 스스로 자리를 고르는 판단(04·05), 포인터와 빛으로 그래프를 들여다보는 장면(06·07)을 지나 막대 모양 자체가 분포와 증감을 말하는 판(08·09)으로 끝난다. 그림은 판마다 캔버스 한 장 위의 2D 그리기 호출로 나오고, 움직임은 16ms 고정 걸음으로 도는 requestAnimationFrame 루프가 맡는다. style.css 의 @keyframes 는 아홉 파일 모두 0개이고, CSS 쪽 전환은 버튼 배경색 하나뿐이다.

2초 녹화 24장으로 잰 움직임

녹화는 판마다 480×300 크기로 2초, 24장이다. 바로 옆 장끼리 23번 견줘 화면이 바뀐 장 수를 셌기 때문에 프레임 칸은 23 을 분모로 적었다.

판 바뀐 화면 넓이(누적면적) 강도 평균 움직인 프레임
01 스프링 성장 34.1% 101.8 23/23
02 순위 경주 42.2% 131.1 23/23
03 누적 층 18.4% 145.5 22/23
04 가로 라벨 16.5% 104.5 23/23
05 눈금 다시 잡기 35.7% 140.5 23/23
06 어안 확대 40.1% 72.0 23/23
07 등축 기둥 16.8% 61.2 23/23
08 입자 히스토그램 24.9% 105.2 23/23
09 증감 기준선 36.4% 100.7 23/23

누적면적이 10% 아래로 내려간 판은 없다. 04 와 07 이 16% 대로 가장 좁은 것은 04 는 막대 끝과 숫자 글자만, 07 은 기둥 면의 밝기와 그림자만 바뀌고 격자 자체는 제자리에 있기 때문으로 보인다. 07 은 강도 평균도 61.2 로 아홉 가운데 가장 낮은데, 넓은 면의 밝기가 조금씩만 달라지는 장면이라 그런 것으로 보인다.

01목표 높이를 한 번 넘었다 돌아오며 스프링으로 자라는 막대

막대마다 높이와 속도를 따로 들고, 16ms 걸음마다 목표 쪽으로 당기는 힘(SPRING_STIFFNESS 170)과 속도를 끄는 힘(SPRING_DAMPING 12)을 더해 속도부터 고친 다음 높이를 옮기며, 출발은 막대마다 70ms 씩 밀려 여덟 번째 막대는 490ms 에야 움직인다. 감쇠비가 12 ÷ (2√170) = 0.46 이라 목표 60 인 막대는 256ms 에 70.6 까지 솟아 목표를 17.7% 넘었다가 640ms 부터 목표 ±0.5 안에 머물고, 빠르게 움직이는 막대일수록 밝게 칠하고 목표선 위로 넘친 부분은 한 겹 덧칠해 넘침이 눈에 걸린다. 매출 대시보드 첫 진입 그래프에 맞는 판이며, 2초 녹화에서 23장 비교 모두 화면이 바뀌었다.

목표 높이를 한 번 넘었다 돌아오며 스프링으로 자라는 막대
새 창에서 열기

01 · 목표 높이를 한 번 넘었다 돌아오며 스프링으로 자라는 막대 — 측정 누적면적 34.125% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_STIFFNESSSPRING_DAMPINGGROW_STAGGER_MS
    function springStep(s, k, d, dt) {
        const a = -k * (s.x - s.to) - d * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    // 감쇠 스프링 — 당기는 힘(1/s²)과 끄는 힘(1/s). 감쇠비 = SPRING_DAMPING / (2√SPRING_STIFFNESS) 가 1 보다 작아 목표를 한 번 넘었다 돌아온다
    const SPRING_STIFFNESS = 170;
    const SPRING_DAMPING = 12;
    // 막대 출발 시차 — 왼쪽 막대부터 이만큼씩 늦게 당기기 시작한다
    const GROW_STAGGER_MS = 70;

02순위가 뒤집힐 때 막대가 줄을 바꿔 달리는 막대 경주

아홉 항목의 가상 값을 키프레임 8개 사이에서 420ms 마다 선형으로 이어 가며 16ms 걸음마다 큰 순서로 다시 정렬하고, 위 6개(TOP_N)만 남긴 뒤 막대의 세로 자리를 새 순위 쪽으로 남은 거리의 12% 씩 당겨 줄 바꿈이 미끄러지게 한다. 한 줄 차이가 10% 로 좁혀지는 데 19걸음 304ms 가 걸려 다음 키프레임 전에 끝나고, 순위가 오른 막대는 잠깐 빛났다가 걸음마다 0.95 배로 식으며, 축 길이는 늦게 따라오더라도 지금의 최고값 아래로는 내려가지 않게 받쳐 선두 막대가 판 오른쪽 끝을 넘지 않는다. 월별 순위 변화를 보여 주는 소개 화면용이고, 아홉 판 가운데 누적면적이 42.2% 로 가장 넓다.

순위가 뒤집힐 때 막대가 줄을 바꿔 달리는 막대 경주
새 창에서 열기

02 · 순위가 뒤집힐 때 막대가 줄을 바꿔 달리는 막대 경주 — 측정 누적면적 42.181% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RACE_FRAME_MSRANK_LERPTOP_N
    // 매 걸음 정렬 — 값이 큰 순서로 순위를 다시 매기고, 순위가 오른 막대는 잠깐 빛난다
    function reRank() {
        const order = [];
        for (let i = 0; i < ENTRY_N; i += 1) {
            order.push(i);
        }
        order.sort(function (a, b) { return cur[b] - cur[a]; });
        for (let r = 0; r < ENTRY_N; r += 1) {
            const i = order[r];
            if (r < rank[i] && r < TOP_N) {
                glow[i] = 1;
            }
            rank[i] = r;
        }
    }

03아래 층부터 차례로 쌓여 오르는 누적 막대

여섯 막대마다 네 계열 값을 아래부터 더해 나간 누적합으로 층의 시작 높이를 정하고, 층 출발은 220ms·막대 출발은 40ms 씩 늦춰 520ms 동안 3제곱 이징 1 − (1 − t)³ 으로 채우며 층 사이에 2px 틈을 둔다. 이징이 앞쪽에 몰려 있어 520ms 가운데 279ms 에 90% 가 차고 마지막 막대의 맨 위 층까지 1,380ms 에 다 쌓이며, 「계열 3 숨기기」 를 누르면 그 층의 가중치가 0 으로 줄면서 위층이 함께 내려앉고 꼭대기 합계도 같은 누적합으로 다시 센다. 채널별 유입 합계 보고서에 붙일 판으로, 움직인 프레임이 22/23 으로 한 장 모자란 것은 다 쌓인 뒤 계열을 숨기기 전까지 잠시 서 있는 틈 때문으로 보인다.

아래 층부터 차례로 쌓여 오르는 누적 막대
새 창에서 열기

03 · 아래 층부터 차례로 쌓여 오르는 누적 막대 — 측정 누적면적 18.397% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEGMENT_DELAY_MSSEGMENT_EASE_POWSTACK_GAP_PX
    // 층 k 의 채움 정도(0~1) — 출발은 층마다 SEGMENT_DELAY_MS, 막대마다 COL_STAGGER_MS 씩 늦다
    function fill(c, k) {
        const t = clamp((buildMs - k * SEGMENT_DELAY_MS - c * COL_STAGGER_MS) / FILL_MS, 0, 1);
        return 1 - Math.pow(1 - t, SEGMENT_EASE_POW);
    }
    // 지금 보이는 값 — 원래 값 × 채움 × 계열 표시 가중치
    function shown(c, k) { return values[c][k] * fill(c, k) * weight[k]; }
    // 누적합 — 층 k 의 시작 높이는 아래 층들의 보이는 값을 더한 것
    function baseOf(c, k) {
        let s = 0;
        for (let j = 0; j < k; j += 1) {
            s += shown(c, j);
        }
        return s;
    }
    function totalOf(c) { return baseOf(c, SERIES_N); }

04라벨이 막대 안에 들어갈지 밖으로 나갈지 스스로 정하는 가로 막대

막대 끝 숫자의 폭을 measureText 로 재서 양옆 8px 여백까지 막대 길이 안에 들어가는지 보고, 막대 색의 상대 휘도로 밝은 글자와 짙은 글자의 대비를 둘 다 계산해 나은 쪽이 CONTRAST_MIN 4.5 이상일 때만 막대 안쪽에 그 색으로 쓴다. 회보라색인 항목 D 의 막대 (128, 120, 150) 는 밝은 글자 3.86·짙은 글자 3.95 로 둘 다 4.5 에 못 미쳐서, 첫 데이터에서 막대가 234px 로 넉넉한데도 「15,940」 이 막대 바깥에 붙는다. 항목 이름이 긴 설문 결과처럼 막대 길이가 제각각인 그래프에 쓰며, 누적면적은 16.5% 로 아홉 가운데 가장 작다.

라벨이 막대 안에 들어갈지 밖으로 나갈지 스스로 정하는 가로 막대
새 창에서 열기

04 · 라벨이 막대 안에 들어갈지 밖으로 나갈지 스스로 정하는 가로 막대 — 측정 누적면적 16.487% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LABEL_PAD_PXCONTRAST_MINBAR_THICKNESS_PX
    // 상대 휘도 — sRGB 채널을 선형으로 펴서 0.2126·0.7152·0.0722 로 섞는다
    function channel(v) {
        const c = v / 255;
        return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    }
    function luminance(c) { return 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]); }
    function contrast(a, b) {
        const la = luminance(a);
        const lb = luminance(b);
        return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
    }
    // 자리 정하기 — 글자 폭 + 양옆 여백이 막대에 들어가고, 두 글자 색 중 나은 쪽이 CONTRAST_MIN 을 넘으면 안쪽
    function placeLabel(text, len, bar) {
        ctx.font = '700 12px sans-serif';
        const width = ctx.measureText(text).width;
        const light = contrast(bar, LIGHT_TEXT);
        const dark = contrast(bar, DARK_TEXT);
        const best = light >= dark ? LIGHT_TEXT : DARK_TEXT;
        const ratio = Math.max(light, dark);
        const fits = width + LABEL_PAD_PX * 2 <= len;
        return { inside: fits && ratio >= CONTRAST_MIN, text: best, ratio: ratio, width: width };
    }

05값이 커지면 눈금이 1·2·5 단위로 다시 잡히며 막대와 함께 줄어드는 축

막대 최댓값을 다섯 칸(TICK_TARGET_COUNT)으로 나눈 대략의 간격을 10의 거듭제곱으로 눌러 1·2·5·10 가운데 그 이상인 첫 값으로 올리고, 축 끝이 바뀔 때만 600ms 동안 옛 눈금은 흐려지고 새 눈금이 들어오며 막대 높이도 같은 최댓값으로 나눠 함께 줄어든다. 「값 늘리기」 를 누르면 최댓값이 46 → 118 → 405 → 922 로 커지는 동안 간격은 10 → 50 → 100 → 200, 축 끝은 50 → 150 → 500 → 1,000 으로 다시 잡힌다. 실시간으로 값이 커지는 방문자 그래프에 맞는 판이고, 강도 평균 140.5 는 03 다음으로 높다.

값이 커지면 눈금이 1·2·5 단위로 다시 잡히며 막대와 함께 줄어드는 축
새 창에서 열기

05 · 값이 커지면 눈금이 1·2·5 단위로 다시 잡히며 막대와 함께 줄어드는 축 — 측정 누적면적 35.748% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TICK_TARGET_COUNTNICE_STEPSRESCALE_MS
    // 1·2·5 계단 — 대략의 간격을 10의 거듭제곱으로 정규화해 NICE_STEPS 가운데 그 이상인 첫 값으로 올린다
    function niceStep(maxValue) {
        const raw = maxValue / TICK_TARGET_COUNT;
        const mag = Math.pow(10, Math.floor(Math.log10(raw)));
        const norm = raw / mag;
        for (let i = 0; i < NICE_STEPS.length; i += 1) {
            if (norm <= NICE_STEPS[i] + 1e-9) {
                return NICE_STEPS[i] * mag;
            }
        }
        return 10 * mag;
    }
    function niceMax(maxValue) {
        const s = niceStep(maxValue);
        return Math.ceil(maxValue / s - 1e-9) * s;
    }

06막대 수십 개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프

막대 60개(BAR_COUNT)의 경계마다 포인터까지의 거리를 반경 80px 로 나눈 u 를 g(u) = (P+1)u ÷ (Pu+1) 로 밀어내는데, 반경 밖 경계는 그대로 두므로 판 전체 폭 432px 는 바뀌지 않는다. 원래 7.2px 이던 막대가 넓어지는 배율은 곡선의 시작 기울기 P+1 = 4 를 넘지 못하고, 초점이 판 가운데일 때 8개가 넓어지며(가장 넓은 막대 22.7px, 약 3.2배) 16개가 반경 가장자리에서 눌린다. 일 단위 기록이 빽빽한 연간 그래프에서 한 구간만 들여다볼 때 쓰며, 초점이 2초에 한 번 판을 왕복하는 시연 동안 23장 비교 내내 움직였다.

막대 수십 개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프
새 창에서 열기

06 · 막대 수십 개 가운데 포인터 근처만 어안 렌즈처럼 넓어지는 막대 그래프 — 측정 누적면적 40.077% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BAR_COUNTFISHEYE_RADIUS_PXFISHEYE_POWER
    // 어안 함수 — 포인터에서 x 까지 거리를 반경으로 나눈 u 를 g(u) 로 밀어낸다. 반경 밖은 그대로라 바깥 자리가 이어진다
    function fisheye(x, fx, power) {
        const d = x - fx;
        const ad = Math.abs(d);
        if (ad >= FISHEYE_RADIUS_PX || power <= 0) {
            return x;
        }
        const u = ad / FISHEYE_RADIUS_PX;
        const g = ((power + 1) * u) / (power * u + 1);
        return fx + Math.sign(d) * FISHEYE_RADIUS_PX * g;
    }
    function edge(i) { return fisheye(PLOT_L + i * barW0, focusX, FISHEYE_POWER * lens); }

07빛 방향에 따라 윗면·옆면 명암이 달라지는 등축 3D 기둥

4×4 격자 칸마다 기둥을 30° 등축 투영으로 세워 윗면과 두 옆면을 구하고, 면 법선과 방위 LIGHT_AZIMUTH_DEG·고도 42° 의 빛 벡터를 내적한 값만큼 바탕 빛 0.3 에 나머지 0.7 을 얹어 면마다 밝기를 정한 뒤 gx + gy 가 작은 뒤 칸부터 칠한다. 시작 방위 135° 에서 +x 옆면은 빛을 등져 바탕 빛 0.3 만 받고 +y 옆면은 0.668, 윗면은 방위와 상관없이 0.768 이며, 바닥 그림자는 기둥 높이 1px 당 1.111px 길이로 빛 반대쪽에 눕는다. 지역별 수치를 입체로 보여 주는 첫 화면용이고, 빛이 2초에 한 바퀴 돌아 화면 넓이는 16.8% 만 바뀌어도 23/23 장이 움직였다.

빛 방향에 따라 윗면·옆면 명암이 달라지는 등축 3D 기둥
새 창에서 열기

07 · 빛 방향에 따라 윗면·옆면 명암이 달라지는 등축 3D 기둥 — 측정 누적면적 16.805% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ISO_ANGLE_DEGLIGHT_AZIMUTH_DEGDEPTH_PX
    // 등축 투영 — 바닥 (wx, wy) 와 높이 wz 를 화면 점으로
    function project(wx, wy, wz) {
        return [OX + (wx - wy) * cosA, OY + (wx + wy) * sinA - wz];
    }
    // 빛 벡터 — 방위각과 고도에서 단위 벡터 (Lx, Ly, Lz)
    function lightVec() {
        const az = azimuth * DEG;
        const el = LIGHT_ELEV_DEG * DEG;
        return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
    }
    // 면 밝기 — 바탕 빛 AMBIENT 에 법선·빛 내적(음수면 0)을 더한다
    function shade(nx, ny, nz, L) {
        return AMBIENT + (1 - AMBIENT) * Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
    }

08떨어진 점들이 칸마다 쌓여 히스토그램이 되는 막대

균등 난수 4개를 평균한 값으로 15칸 가운데 하나를 골라 점 360개를 위에서 떨어뜨리고, 중력 1,400px/s² 로 속도를 쌓다가 미리 예약해 둔 제 자리 높이에 닿으면 속도에 0.35 를 곱해 튕기고, 초속 70px 이하면 멈춰 쌓인다. 평균을 내면 가운데 칸 몫이 17.4% 로 두꺼워지고(「균등 난수 하나로」 를 누르면 칸마다 약 6.7%), 시드 실행에서는 가장 높은 칸이 360개 중 60개를 받았고, 234.5px 를 떨어지는 점 하나는 제 자리에 세 번 닿은 뒤 1,136ms 에 멈춘다. 응답 시간 분포를 설명하는 기술 블로그에 붙이기 좋은 판으로, 누적면적 24.9%·강도 105.2 가 나왔다.

떨어진 점들이 칸마다 쌓여 히스토그램이 되는 막대
새 창에서 열기

08 · 떨어진 점들이 칸마다 쌓여 히스토그램이 되는 막대 — 측정 누적면적 24.856% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DROP_GRAVITYBIN_COUNTBOUNCE_LOSS
        for (let i = 0; i < dots.length; i += 1) {
            const d = dots[i];
            if (d.rest) {
                continue;
            }
            d.v += DROP_GRAVITY * dt;
            d.y += d.v * dt;
            // 제 자리(쌓인 높이)에 닿으면 튕기거나, 충분히 느리면 멈춰 쌓인다
            if (d.y >= d.restY) {
                d.y = d.restY;
                if (d.v > REST_SPEED) {
                    d.v = -d.v * BOUNCE_LOSS;
                }
                else {
                    d.v = 0;
                    d.rest = true;
                    landed[d.bin] += 1;
                }
            }
        }

09증가는 위로·감소는 아래로 갈라지고 0 기준선이 함께 움직이는 증감 막대

값의 최솟값·최댓값을 0 과 견줘 축 범위가 0 을 빠뜨리지 않게 잡은 뒤 위아래로 범위의 12% 를 더하고, 0 기준선은 축 위 끝에서 0 까지가 전체 범위에서 차지하는 비율로 높이를 정한다. 데이터가 바뀌면 축 양 끝을 걸음마다 남은 거리의 10% 씩 옮기므로 1번에서 2번 데이터로 넘어갈 때 기준선이 y 235.3 에서 81.4 로 올라가며 368ms 만에 90% 를 지나고, 0 둘레 ±8 안의 작은 값은 감소색과 증가색이 섞여 회색 쪽으로 넘어간다. 전월 대비 증감을 보여 주는 요약 카드에 맞고, 누적면적은 36.4% 로 기준선과 그 위아래 바탕 띠가 함께 오르내리는 몫이 큰 것으로 보인다.

증가는 위로·감소는 아래로 갈라지고 0 기준선이 함께 움직이는 증감 막대
새 창에서 열기

09 · 증가는 위로·감소는 아래로 갈라지고 0 기준선이 함께 움직이는 증감 막대 — 측정 누적면적 36.38% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BASELINE_LERPZERO_CROSS_FADEDIVERGE_PAD_PCT
    // 축 범위 — 0 을 반드시 품고(min(0,·)·max(0,·)) 값 범위의 DIVERGE_PAD_PCT 만큼 위아래로 넓힌다
    function rangeFor() {
        let lo = 0;
        let hi = 0;
        for (let i = 0; i < BAR_N; i += 1) {
            lo = Math.min(lo, goalOf(i));
            hi = Math.max(hi, goalOf(i));
        }
        const pad = (hi - lo) * DIVERGE_PAD_PCT / 100;
        return { lo: lo - pad, hi: hi + pad };
    }
    const axis = rangeFor();
    // 0 기준선 높이 — 축 위 끝에서 0 까지가 전체 범위에서 차지하는 비율
    function zeroY() { return PLOT_T + (axis.hi / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
    function yOf(v) { return PLOT_T + ((axis.hi - v) / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }

값 하나는 어떻게 화면 위 막대 높이 몇 픽셀이 되는가?

막대 높이는 값을 축 끝 값으로 나눈 비율에 그래프 판의 높이를 곱한 길이다. 01 은 판이 y 64 에서 262 까지 198px 이고 축이 0~100 이라 값 1 이 1.98px 가 되고, 목표 60 인 막대는 바닥에서 118.8px 솟는다. 막대 폭은 판 너비 418px 를 여덟 칸으로 나눈 52.25px 에서 사이 간격 14px 를 뺀 38.25px 다.

축 끝 값이 바뀌면 같은 값도 다른 높이가 된다

이 비례식에서 움직이는 것은 값만이 아니다. 05 는 나누는 수인 축 끝 값을 600ms 동안 50 에서 150 으로 옮기는데, 100ms 간격으로 보면 59.2 → 81.4 → 109 → 134 → 148.7 로 가운데가 빠른 곡선을 그린다. 값이 그대로라면 같은 막대의 높이가 3분의 1 로 줄어드는 셈이다. 막대만 따로 줄이지 않고 축과 같은 수로 나누기 때문에 눈금선과 막대 끝이 어긋나지 않는다.

가로 막대는 같은 식을 x 축으로 돌린 것

가로 막대 그래프 만들기도 비례식은 같고 길이를 재는 방향만 바뀐다. 04 는 왼쪽 시작점 x 70 에서 오른쪽 끝 452 까지를 0~26,000 에 맞춰 값 1 을 0.01469px 로 바꾸므로, 15,940 은 234px 짜리 막대가 된다.

0 이 판 가운데에 오는 증감 막대

09 처럼 음수가 섞이면 바닥이 판 아래 끝이 아니라 0 의 자리로 옮겨 간다. 기준선 y 는 58 + hi ÷ (hi − lo) × 214 로, 첫 데이터는 축이 −10.78~52.08 이라 0 이 위에서 82.8% 지점인 y 235.3 에 놓이고, 둘째 데이터는 −49.03~6.03 이라 10.9% 지점인 y 81.4 까지 올라간다.

화면이 커져도 좌표가 480×300 인 까닭

캔버스 안 좌표는 아홉 판 모두 480×300 으로 고정이고, 실제 화소만 Math.min(window.devicePixelRatio || 1, 2) 배로 늘린다. 포인터 위치도 getBoundingClientRect 비율로 이 좌표에 되돌려 계산하니, 무대가 폰 폭으로 줄어도 위에 적은 픽셀 수는 같은 식으로 읽으면 된다.

눈금을 1·2·5 단위로 고르는 계산과, 막대가 목표 높이를 한 번 넘었다 돌아오는 이유

축 눈금이 23.6 이나 184.4 같은 간격으로 나오면 읽는 사람이 막대 높이를 머릿속으로 나누기 어렵다. 05 는 대략의 간격을 먼저 구하고 그 수를 10의 거듭제곱으로 눌러 1 과 10 사이의 수로 만든 뒤, 1·2·5·10 가운데 그보다 크거나 같은 첫 값을 골라 다시 거듭제곱을 곱한다.

05 의 네 단계에서 고른 간격

단계 막대 최댓값 대략의 간격 고른 간격 축 끝
처음 값 46 9.2 10 50
1번 늘림 118 23.6 50 150
2번 늘림 405 81 100 500
3번 늘림 922 184.4 200 1,000

118 은 23.6 을 10 으로 나눈 2.36 이 2 를 넘어서 5 로 올라가 간격 50 이 되고, 922 는 1.844 가 2 로 올라가 간격 200 이 된다. 같은 함수에 다른 값을 넣으면 7 은 간격 2·축 8, 1,234 는 간격 500·축 1,500, 4,321 은 간격 1,000·축 5,000 이 나온다. 축 끝은 최댓값을 간격으로 나눠 올림한 칸 수에 간격을 곱한 값이라, 922 의 축이 1,000 에서 멈추듯 가장 높은 막대 바로 위 눈금에서 판이 끝난다.

감쇠비가 1 보다 작으면 목표를 한 번 지나친다

01 의 가속도는 −강성 × (높이 − 목표) − 감쇠 × 속도 하나뿐이다. 감쇠 ÷ (2√강성) 으로 구하는 감쇠비가 1 보다 작으면 막대는 목표에 닿는 순간에도 위로 가는 속도가 남아 있어 그만큼 지나쳤다가 당겨져 돌아오고, 170 과 12 의 감쇠비 0.46 에서는 그 넘침이 17.7% 다. 목표 ±0.5 띠에 처음 들어가는 것은 416ms 지만 한 번 더 빠져나갔다가 640ms 부터 머문다.

속도부터 고치는 순서가 버티는 이유

springStep 은 가속도로 속도를 먼저 바꾸고 그 새 속도로 높이를 옮긴다. 옛 속도로 높이부터 옮기는 순서보다 16ms 고정 걸음에서 덜 튀는 쪽이라 이 순서를 골랐고, 감쇠 12 에서는 출렁임이 차츰 줄어 640ms 부터 목표 ±0.5 안에 머문다.

막대가 수백 개일 때·값이 0 근처일 때·라벨이 막대보다 길 때 — 막대 그래프가 깨지는 곳

막대가 수백 개면 어안 가장자리가 1px 밑으로 사라진다

06 은 폭 합계가 432px 로 묶여 있어 가운데를 넓힌 만큼 반경 가장자리가 눌리고, 막대 60개에서도 가장 가는 막대가 2.0px 까지 줄어 막대 사이 틈은 0.4px 만 남는다. 432px 를 막대 수로 나눈 원래 폭은 200개만 돼도 2.2px 라, 막대를 수백 개로 늘리면 눌린 막대가 1px 밑으로 내려가 안티에일리어싱 번짐에 묻힐 것으로 보인다. 02 처럼 걸음마다 전부 정렬하는 판도 막대가 늘수록 정렬 비용이 커지니, 그때는 키프레임이 바뀔 때만 다시 정렬하는 식으로 줄인다.

같은 값이 둘이면 경주 순서는 정렬 구현이 정한다

02 의 비교 함수는 cur[b] - cur[a] 하나라 값이 같으면 0 을 돌려주고, 그 둘의 순서는 브라우저 정렬에 맡겨진다. 시드 데이터도 첫 키프레임에 34 가 셋이라 시작 줄 순서가 여기에 기대고 있으니, 내 데이터에 동점이 잦다면 이름순 같은 두 번째 기준을 비교에 덧붙인다.

최댓값이 0 이거나 값이 전부 0 일 때

05 는 최댓값이 0 이면 Math.log10(0) 이 음의 무한대가 되어 간격이 NaN 으로 무너지고, 09 는 값이 전부 0 이면 hi − lo 가 0 이라 기준선 식의 나눗셈이 깨진다. 데모 값은 05 가 12 이상이라 터지지 않지만 실제 보고서에는 기록이 빈 달이 나올 수 있으니 0 을 따로 걸러야 한다. 09 는 ±8 안의 작은 증감이 회색 쪽으로 섞여 색만으로는 부호가 흐려지므로, 이름표에 +/− 를 붙여 색 말고도 부호를 읽게 했다.

숫자 글자가 막대보다 길 때

04 에서 쉼표까지 다섯 글자인 「3,297」 은 31.0px, 여섯 글자인 「15,940」 은 37.9px 로 재여 양옆 여백을 더하면 47.0px·53.9px 가 있어야 막대 안에 들어간다. 「막대 줄이기」 로 값을 0.3 배로 낮추면 일곱 줄이 모두 바깥으로 나가고, 막대가 가장 짧은 줄은 6px 이다. 글자 폭은 measureText 가 그 순간 쓰는 글꼴로 재므로 웹 글꼴을 쓴다면 글꼴이 늦게 들어오는 동안 판정이 달라질 수 있다(이 데모는 sans-serif 그대로다).

흐리게 낮춘 줄도 원래 색으로 대비를 잰다

04 에서 줄 하나를 고르면 나머지 줄이 흐려지는데, 글자 색 판정은 흐리기 전 막대 색으로 한다. 그래서 흐린 줄 글자의 실제 대비는 계산값보다 낮아지고, 반투명 막대를 쓰려면 바탕과 섞인 뒤의 색으로 휘도를 다시 재야 한다.

음수와 얇은 층은 누적 막대를 어긋나게 한다

03 의 누적합은 양수만 가정하므로 음수를 넣으면 층의 시작 높이가 아래로 꺾여 층끼리 겹친다. 계열이 많아 층이 2px 틈보다 얇아지면 틈만 보이게 되므로, 이 판은 층 높이가 2px 보다 작을 때 틈을 그 높이만큼만 연다.

칠하는 순서 하나로 가림을 맞추는 한계

07 은 gx + gy 가 작은 칸부터 칠하는 순서 하나로 앞뒤를 맞추기 때문에 칸 크기가 다른 기둥이나 서로 겹치는 기둥에서는 가림이 틀린다. 그림자 길이는 높이에 (Lx, Ly) ÷ Lz 를 곱한 값이라 빛 고도를 0° 쪽으로 내리면 Lz 가 0 에 다가가 그림자가 끝없이 길어지고, 그림자는 바닥에만 눕고 다른 기둥 몸통을 타고 오르지 않는다.

감쇠를 올리면 넘침이, 강성을 올리면 안정이 사라진다

01 은 감쇠를 26 쯤으로 올리면 감쇠비가 1 에 닿아 넘침이 없어지면서 이 판 이름의 동작이 사라지고, 강성을 크게 올리면 16ms 고정 걸음에서 수치가 튈 수 있다. 출렁이다 0 아래로 내려간 높이는 Math.max(0, b.x) 로 잘라 막대가 바닥을 뚫고 내려가지 않게 했다.

점끼리는 서로 부딪치지 않는다

08 은 점이 나오는 순간 그 칸에서 앉을 자리를 먼저 예약하므로, 늦게 나온 점이 아직 떨어지고 있는 점을 지나쳐 제 자리로 내려간다. 점을 수만 개로 늘리면 멈춘 점은 계산을 건너뛰어도 그리기는 전부 다시 하므로 그리는 쪽 비용이 먼저 커진다.

판마다 index.html·style.scss·style.css 를 담은 vanilla 폴더와 같은 계산을 옮긴 react 폴더를 묶어 둔 파일은 jm3zs7sw 을 넣어야 풀리고, 위에서 본 함정을 내 데이터로 직접 재현해 볼 때 쓰면 된다.

스프링 감쇠·순위 바뀌는 빠르기·어안 반경을 옮기는 변형, 그리고 키보드와 상태 줄로 값을 읽히는 접근성

이름 바꾼 값 느낌
스프링 감쇠 키우기 01 SPRING_DAMPING 12 → 26 감쇠비가 0.46 에서 0.997 로 1 에 거의 닿아 목표 60 막대가 70.6 까지 솟던 넘침이 없어지고, 막대가 곧게 올라와 592ms 에 자리 잡는다(12 일 때는 640ms).
순위 따라가는 빠르기 올리기 02 RANK_LERP 0.12 → 0.3 한 줄 차이를 10% 로 줄이는 데 19걸음 304ms 이던 것이 7걸음 112ms 로 짧아져 새 줄에 거의 바로 붙고, 겹쳐 지나가는 추월 순간은 덜 보인다. 둘 다 키프레임 간격 420ms 안이다.
어안 반경 넓히기 06 FISHEYE_RADIUS_PX 80 → 160 넓어지는 막대가 8개에서 14개, 눌리는 막대가 16개에서 32개로 늘고 가장 넓은 막대는 22.7px 에서 25.4px 가 되어 한 구간이 통째로 펼쳐진다. 폭이 그대로인 막대는 36개에서 14개로 준다.

마우스 없이 막대를 고르는 키

아홉 캔버스 모두 tabindex="0" 과 한국어 aria-label 을 달아 탭으로 들어갈 수 있다. ←/→ 는 막대를 옮기고(02 는 지금 순위 차례, 07 은 칸 번호 차례), Enter·Space 는 그 막대를 고정하거나 같은 막대면 풀며, Escape 는 고정을 푼다. 셋 다 기본 동작을 막은 뒤 오른쪽 위 상태 줄이 「항목 C · 87」, 04 는 「항목 D · 15,940 · 바깥 4.0:1」 처럼 이름과 값을 적는다.

가리킨 막대와 고정한 막대의 테두리

포인터를 올리기만 한 막대는 [4, 3] 간격의 점선, 눌러 고정한 막대는 실선 테두리를 둘러 색을 구분하지 못해도 상태가 갈린다. 캔버스와 버튼의 키보드 포커스 테두리는 style.css 의 :focus-visible 이 그린다.

움직임 줄이기를 켜 둔 화면

아홉 판 모두 matchMedia('(prefers-reduced-motion: reduce)') 에 change 리스너를 걸어, 페이지를 연 채로 설정을 바꿔도 곧바로 따른다. 줄이기 쪽이면 애니메이션 루프를 세우고 목표 상태로 바로 옮겨 한 번 그리며, 그 상태에서도 키·버튼·클릭은 결과를 화면에 바로 반영한다. 02 는 이때 처음 화면이 마지막 키프레임이라 일시 정지 버튼은 화면은 그대로 두고 눌림 상태만 바꾸며, style.css 의 같은 미디어 쿼리는 버튼 배경색 전환까지 끈다.

css 막대 그래프 애니메이션만으로는 어디까지 되고 어디서 캔버스가 필요한가?

정해 둔 높이까지 막대 몇 개를 키우는 정도라면 CSS 의 높이 전환만으로도 충분하다. 다만 이 편의 아홉 판은 CSS 가 아니라 캔버스와 스크립트로 만들었고, 그 까닭은 판마다 값을 읽어 다음 화면을 정하는 계산이 들어가서다. 02 는 16ms 마다 값을 정렬해 순위를 다시 매기고, 04 는 글자 폭과 휘도를 재서 라벨 자리를 고르며, 05 는 최댓값에서 눈금 간격을 구한다. 06 의 경계 재배치, 07 의 면 법선과 빛 내적, 08 의 중력 적분도 매 걸음 숫자를 다시 계산해야 해서 스크립트의 몫이 된다. 막대는 fillRect 와 경로 채우기로, 숫자 폭은 measureText 로, 시간은 requestAnimationFrame 으로 다뤘다.

같은 데이터 시각화 줄기에서 원 조각을 다루는 편은 원형 그래프 만들기 9종, 선과 막대를 SVG 로 긋는 편은 SVG 그래프 9종 이다. 캔버스 차트 전반은 캔버스 차트 애니메이션 9종 과 데이터 시각화 차트 9종, 화소 계산으로 장면을 채우는 쪽은 캔버스 특수효과 9종 으로 이어진다.

막대 그래프를 보고서 화면에 붙이기 전에 자주 확인하는 것

가로 막대 그래프 만들기에도 01 의 스프링을 그대로 쓸 수 있나?

쓸 수 있다. springStep 은 높이·속도·목표 숫자 세 개만 다루고 방향을 모르니, 04 의 막대 길이에 붙여도 같은 넘침이 난다. 04 는 지금 길이를 목표 쪽으로 걸음마다 12% 씩 당겨 넘치지 않고, 걸음마다 지금 길이 len[i] 로 라벨 안팎을 다시 판정한다. 그래서 스프링으로 바꾸면 길이가 출렁이는 동안 판정도 흔들릴 수 있으니, 판정에만 목표 길이를 넘기는 편이 낫다.

내 숫자로 바꾸려면 어디를 고치나?

01 은 seededValues(seed, BAR_N, 30, 94) 로 만든 목표 배열을 내 값 여덟 개로 바꾸면 된다. 01 의 축은 0~100 으로 고정이라 100 을 넘는 값이면 AXIS_MAX 를 함께 고치거나, 05 의 niceStep·niceMax 를 가져와 축 끝을 값에서 구한다. 09 는 축과 기준선을 값에서 계산하므로 sets 의 배열만 바꿔도 0 의 자리가 따라온다.

화면 낭독기 사용자는 막대 값을 어떻게 듣나?

캔버스 안의 막대는 하나씩 읽히는 요소가 아니어서, 이 데모는 캔버스에 aria-label 한 줄을 달고 고른 막대의 값은 상태 줄로 전한다. 상태 줄의 aria-live 는 자동 시연 동안 off 로 두어 시연이 바뀔 때마다 읽히지 않게 하고, 사람이 처음 키·버튼·포인터를 누르는 순간 polite 로 바뀐다. 보고서처럼 전체 숫자를 한 번에 확인해야 하는 화면이라면 그래프 옆이나 아래에 같은 값을 담은 table 을 함께 두는 편이 낫고, 이 데모에는 그 표가 들어 있지 않다.

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

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