css 그래프 만들기 SVG로 9종 — 라이브러리 없이
css 그래프는 숫자를 눈에 보이는 선·막대·면으로 바꾸는 가장 가벼운 방법입니다. 여기 아홉 가지는 선·막대·도넛·레이더·히트맵·스파크라인·누적 영역·트리맵·게이지를 라이브러리 없이 SVG 좌표 계산과 CSS 애니메이션만으로 그린 것이라, 파일을 붙여 넣으면 그려지는 등장 동작과
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 펜처럼 그려지는 선 그래프
- 02 밑변에서 자라는 세로 막대 그래프
- 03 한 조각씩 차오르는 도넛 그래프
- 04 중심에서 펼쳐지는 레이더 차트
- 05 칸이 순서대로 물드는 히트맵
- 06 카드 안에 사는 스파크라인
- 07 아래에서 채워지는 누적 영역 그래프
- 08 큰 것부터 자리 잡는 트리맵
- 09 숫자를 넣으면 바늘이 도는 반원 게이지
아홉 개를 늘어놓은 기준은 그래프가 그림을 완성하는 순서입니다. 먼저 선을 긋는 셋 — x·y 계산으로 path 를 잇고(01), 막대가 밑변에서 자라며(02), 원호 조각이 차례로 차오릅니다(03). 다음은 면을 채우는 셋 — 중심에서 폴리곤이 펼쳐지고(04), 격자 칸이 농도로 물들며(05), 누적 영역이 아래부터 쌓입니다(07). 마지막 셋은 자리를 나누고 값을 가리키는 쪽 — 카드 안의 짧은 추이(06), 면적대로 분할된 트리맵(08), 값 하나를 각도로 바꾸는 게이지(09)입니다. 무대 색은 잉크 셋·종이 셋·노랑 둘·주황 하나로 돌려 격자 아홉 칸의 밝기가 한쪽으로 쏠리지 않게 맞췄습니다. 그린 그래프를 마우스로 다루는 쪽은 대시보드 차트 인터랙션 9종에, 원형 링 하나로 수치를 세는 쪽은 원형 프로그레스바 스탯 링 9종에 있습니다.
01펜처럼 그려지는 선 그래프
12개월 방문 수를 scaleX·scaleY 두 함수로 viewBox 좌표로 바꿔 path 하나로 잇고, pathLength=100 을 붙인 뒤 stroke-dashoffset 을 100 에서 0 으로 내려 왼쪽에서 끝까지 긋습니다. 꺾이는 점마다 인접 점의 중점을 잇고 실제 점은 제어점으로 쓰는 부드러운 선이라 꺾임이 살면서 물결지지 않습니다. 펜이 끝까지 그어가는 루프에서 누적면적 25.276%, 움직인 프레임 18/23 을 기록했고, 월간 트래픽 리포트 상단 요약과 어드민 대시보드 첫 카드에 씁니다.
// 스케일 함수 — 데이터 값 → viewBox 좌표. 이 두 함수가 그래프의 전부다
function scaleX(i) { return PAD_L + (PAD_R - PAD_L) * i / (DATA.length - 1); }
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }
// 인접 점의 중점을 잇고 실제 점은 제어점으로 쓰는 부드러운 선 — 꺾임이 살면서 물결지지 않는다
function buildPath() {
var pts = DATA.map(function (v, i) { return [scaleX(i), scaleY(v)]; });
var d = 'M' + pts[0][0] + ',' + pts[0][1];
for (var i = 1; i < pts.length - 1; i++) {
var mx = (pts[i][0] + pts[i + 1][0]) / 2, my = (pts[i][1] + pts[i + 1][1]) / 2;
d += ' Q' + pts[i][0] + ',' + pts[i][1] + ' ' + mx + ',' + my;
}
var last = pts[pts.length - 1];
d += ' L' + last[0] + ',' + last[1];
return d;
}
02밑변에서 자라는 세로 막대 그래프
주간 매출을 막대 하나씩 셀 하나로 묶어 --d 지연으로 순서대로 세우고, transform: scaleY 를 transform-origin: 50% 100% 와 함께 써 밑변 축에서 위로 자라게 합니다 — baseline 에 닿는 아래쪽이 원점이어야 커지는 게 아니라 늘어나 보입니다. 값 라벨은 반값과 최종값 두 장을 컷으로 갈아 끼우고, 최고 막대(토요일)만 계열색 블루로 강조합니다. 루프에서 누적면적 14.742%, 강도 166.4 를 기록했고, 주간 매출 요약 카드처럼 이번 주 최고치를 강조하는 자리에 씁니다.
// 데이터 값 → viewBox 좌표. 막대 그래프의 핵심은 이 두 함수다
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }
function band() { return (PAD_R - PAD_L) / DATA.length; } // 막대 하나가 차지하는 폭
function centerX(i) { return PAD_L + band() * (i + .5); } // 막대 중심 = 라벨 위치
// SVG 자식을 만드는 헬퍼 — 속성만 채워 부모에 붙인다
function el(name, attrs, parent) {
var n = document.createElementNS(SVGNS, name);
for (var k in attrs) { n.setAttribute(k, attrs[k]); }
parent.appendChild(n);
return n;
}
// 막대의 scaleY 는 밑변 축에 붙은 채 위로 자란다 — baseline 에 닿는 아래쪽이 원점이므로
// transform-origin bottom 이 정당하다. transform-box: fill-box 로 rect 자체를 기준 박스로
.cb__bar { transform-box: fill-box; transform-origin: 50% 100%; }
03한 조각씩 차오르는 도넛 그래프
유입 채널 비중을 12시에서 시작해 시계 방향으로 각도를 쓰며 원호 A 명령 네 개로 계산하고, 조각마다 pathLength=100 을 붙여 stroke-dashoffset 이 원호 길이와 무관하게 100 에서 0 으로 내려가게 합니다. 조각 사이 2px 간격은 path 를 각도만큼 줄여 그렸으므로 크림 배경이 자연스러운 분할선이 됩니다. 이어지는 붓질이 2초 루프를 채워 누적면적 7.767%, 강도 169.0 을 기록했고, 유입 채널 비중 카드처럼 구성 비율을 한눈에 보는 자리에 씁니다.
// 시작·끝 각도(라디안)로 원호 path 한 조각 — M 으로 붓을 대고 A 로 호를 긋는다
function arcPath(a0, a1) {
var x0 = (CX + R * Math.cos(a0)).toFixed(2), y0 = (CY + R * Math.sin(a0)).toFixed(2);
var x1 = (CX + R * Math.cos(a1)).toFixed(2), y1 = (CY + R * Math.sin(a1)).toFixed(2);
var large = (a1 - a0) > Math.PI ? 1 : 0; // 180도 넘는 호에만 큰 호 플래그
return 'M' + x0 + ',' + y0 + ' A' + R + ',' + R + ' 0 ' + large + ' 1 ' + x1 + ',' + y1;
}
04중심에서 펼쳐지는 레이더 차트
여섯 축 점수를 pos(i, r) 삼각함수 좌표로 놓아 눈금 육각 격자와 데이터 폴리곤이 같은 함수에서 나오게 합니다. 폴리곤은 transform: scale 로 중심에서 펼쳐지는데, SVG 요소의 CSS transform 기준 상자는 기본 viewBox 원점이라 transform-box: fill-box 와 transform-origin: center 를 함께 써야 폴리곤 한가운데서 커집니다. 폴리곤 확대 뒤 꼭짓점의 점 여섯이 순서대로 팝하는 루프에서 누적면적 5.192%, 움직인 프레임 19/23 을 기록했고, 설문 응답 비교와 기능 만족도 카드에 씁니다.
// i번째 축의 좌표 — 위꼭짓점(i=0)에서 시작해 시계 방향 60도 간격.
// r 에 점수 비율을 곱하면 데이터 위치, 눈금 반지름을 그대로 넣으면 격자 꼭짓점이 된다
function pos(i, r) {
var rad = (i * 60 - 90) * Math.PI / 180;
return [CX + r * Math.cos(rad), CY + r * Math.sin(rad)];
}
// 반지름 r 육각형의 points 문자열 — 눈금 격자와 데이터 폴리곤이 같은 함수에서 나온다
function ringPts(r) {
var s = '';
for (var i = 0; i < N; i++) { var p = pos(i, r); s += (i ? ' ' : '') + p[0].toFixed(1) + ',' + p[1].toFixed(1); }
return s;
}
05칸이 순서대로 물드는 히트맵
요일×시간대 밀도 42칸을 한 가지 색으로 고정하고 fill-opacity 만 0.12 + 0.88 × v / 100 농도 함수로 움직입니다. 칸마다 animation-delay 를 28ms 간격으로 달아 좌에서 우로, 한 줄이 끝나면 다음 줄로 순서대로 물들게 하는데, 지연이 걸린 루프에는 animation-fill-mode: backwards 가 필수라 첫 프레임에 완성 상태가 번쩍 지나가지 않습니다. 42칸이 전부 물드는 루프에서 누적면적 29.057%, 움직인 프레임 22/23 을 기록했고, 방문 시간대 밀도 패널과 활동 리포트에 씁니다.
// 농도 함수 — 이 데모의 주제. 색은 한 가지로 고정하고 투명도만 값에 비례해 움직인다
function fillOpacity(v) { return 0.12 + 0.88 * v / 100; }
// 칸마다 --d 지연으로 좌에서 우로 순서대로 나타난다.
// 낱개 속성 + animation-fill-mode: backwards — animation 단축은 delay 를 0 으로 리셋하고,
// fill-mode 가 없으면 지연 구간에 완성 상태가 첫 프레임에 번쩍 지나간다
.ch.is-demo .ch__cell {
animation-name: chCell;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d);
animation-fill-mode: backwards;
}
06카드 안에 사는 스파크라인
14일 추이를 카드 폭에 맞는 짧은 path 로 줄이고, 종가 점이 라인과 같은 계열색으로 마침표를 찍습니다. 큰 숫자 두 장(반값·최종값)을 같은 grid 칸에 겹쳐 steps(1, end) 컷으로 갈아 끼우므로 숫자가 바뀌어도 자리가 흔들리지 않습니다. 라인 드로잉·점 팝·숫자 컷 교체가 이어지는 루프에서 강도 128.8 을 기록했고, KPI 카드 안의 미니 추이처럼 목록 행의 보조 지표에 씁니다.
function buildPath() {
return DATA.map(function (v, i) {
return (i ? 'L' : 'M') + scaleX(i).toFixed(1) + ',' + scaleY(v).toFixed(1);
}).join(' ');
}
line.setAttribute('d', buildPath());
// 종가 점 — 상승 추세라 라인과 같은 계열색. 팝은 점 중심에서 커진다
.cs__dot { fill: $chart-1; transform-box: fill-box; transform-origin: center; }
07아래에서 채워지는 누적 영역 그래프
세 계열 점유율을 매월 100 이 되게 더해 누적 경계선 네 개(바닥·데스크톱 위·모바일 위·천장)를 잡고, 각 레이어 path 가 위 경계를 좌에서 우로 걷고 아래 경계를 거꾸로 돌아 면적을 닫습니다. 미리보기는 같은 path 에 clip-path: inset 와이프를 얹어 아래 레이어부터 차례로 채워 올립니다. 세 층이 순서대로 차오르는 루프에서 누적면적 33.087%, 강도 160.0 을 기록했고, 기기·브라우저 점유율 추이 패널에 씁니다.
// k번째 레이어 path — 위 경계를 좌에서 우로 걷고 아래 경계를 거꾸로 돌아와 면적을 닫는다
function layerPath(k) {
var d = '';
for (var i = 0; i < MON.length; i++) d += (i ? ' L' : 'M') + xAt(i).toFixed(1) + ',' + yAt(B[k + 1][i]).toFixed(1);
for (var j = MON.length - 1; j >= 0; j--) d += ' L' + xAt(j).toFixed(1) + ',' + yAt(B[k][j]).toFixed(1);
return d + ' Z';
}
08큰 것부터 자리 잡는 트리맵
카테고리 매출을 값 내림차순으로 정렬해 남은 영역을 절반 무게에 가장 가까운 지점으로 이진 분할하고, 종횡비가 1 에 가까워지는 방향으로 재귀하는 간이 squarify 로 배치합니다. 라벨은 면적이 충분한 칸에만 새기고 작은 칸은 호버 툴팁과 스크린리더 표가 값을 갖습니다. 큰 칸부터 순차 등장하는 루프에서 누적면적 53.981% 를 기록했고, 카테고리 매출 구성 패널과 포트폴리오 구성 뷰에 씁니다.
// 간이 squarified 이진 분할 — 값 내림차순 목록을 남은 영역에서 절반 무게에 가장 가까운 지점으로 나누고
// 종횡비가 1 에 가까워지는 방향(가로/세로)으로 재귀한다. 완전한 squarify 는 아니지만 정사각 비율이 잘 나온다
function split(items, x, y, w, h, out) {
if (!items.length) { return; }
if (items.length === 1) { out.push({ item: items[0], x: x, y: y, w: w, h: h }); return; }
var total = 0;
for (var k = 0; k < items.length; k++) { total += items[k].v; }
var half = total / 2, acc = 0, i = 0;
while (i < items.length - 1 && Math.abs(acc + items[i].v - half) < Math.abs(acc - half)) { acc += items[i].v; i++; }
var ratio = acc / total;
if (w >= h) {
split(items.slice(0, i), x, y, w * ratio, h, out);
split(items.slice(i), x + w * ratio, y, w * (1 - ratio), h, out);
} else {
split(items.slice(0, i), x, y, w, h * ratio, out);
split(items.slice(i), x, y + h * ratio, w, h * (1 - ratio), out);
}
}
09숫자를 넣으면 바늘이 도는 반원 게이지
목표 달성률을 입력하면 값% 가 곧 stroke-dashoffset(100-값)이 되어 반원 호가 차고, 바늘은 rotate(각도) 로 그 값의 각도만큼 돕니다. SVG 요소의 CSS transform-origin 은 viewBox 좌표계를 기준하므로 게이지 중심(220px 126px)을 직접 지정합니다. 0 에서 68 로 바늘이 돌며 차오르는 루프에서 강도 141.5 를 기록했고, 목표 달성률 표시와 할당량 진행 카드에 씁니다.
// 값 → 바늘 회전각(도). 바늘 기본 모양이 12시라 0% 에서 -90(왼쪽 끝), 100% 에서 +90(오른쪽 끝)
function angleFor(v) { return v * 1.8 - 90; }
// 값 → 반원 위의 수학 각도(도). 0% 가 180(왼쪽), 100% 가 0(오른쪽) — 회전각과 다른 값이라는 점이 함정
function degreeFor(v) { return 180 - v * 1.8; }
// 수학 각도 → viewBox 좌표. SVG 는 y 가 아래로 자라므로 sin 을 **빼야** 위쪽 반원이 나온다
function pointAt(deg, radius) {
var rad = deg * Math.PI / 180;
return [CX + radius * Math.cos(rad), CY - radius * Math.sin(rad)];
}
어디서 깨지는가 — 함정
첫 번째 함정은 각도의 두 규약을 한 식에 섞는 것입니다. SVG 의 y 축은 아래로 자라므로 수학 각도로 좌표를 잡을 때는 sin 을 빼야 위쪽 반원이 나오는데, 이 편의 09 에서 처음에는 CSS 회전각의 감각으로 CY + radius * Math.sin(rad) 를 썼다가 반원이 통째로 아래로 뒤집혔습니다. 구간 60~80% 의 중심각 54도에서 반지름 88 이면 잘못된 y 는 197.2 — viewBox 높이 176 을 21.2 나 넘겨 호버 툴팁의 당김줄이 밑변 아래로 가라앉았고, sin 을 빼는 한 줄로 54.8 로 되돌아왔습니다(근거: run/289/_수리전실측.json). 바늘의 CSS rotate 는 시계 방향, 수학 각도는 반시계 방향이라 두 값은 애초에 다른 숫자라는 점도 함께 적어 둡니다. 두 번째는 절대 배치 툴팁을 카드 위에 띄우는 것 — 07 에서 한 줄에 계열 셋을 늘어놓은 툴팁이 폰 폭에서 화면 밖으로 37px 나 나갔고, 그래프 안쪽에 두고 세로로 쌓아야 320px 안에 들어옵니다. 세 번째는 되감기 장면이 대표 이미지를 잡아먹는 것 — 루프 끝에 그래프를 지우는 순간의 변화 면적이 등장보다 크면 poster 가 빈 카드가 되므로, 완성 상태를 96% 까지 유지하고 되돌림은 98% 로 미뤄 샘플 밖으로 내보냅니다.
| 함정 | 증상 | 고침 |
|---|---|---|
| sin 부호를 CSS 감각으로 | 반원이 아래로 뒤집힘 | y 축이 아래로 자라므로 CY - r*sin(rad) |
| 툴팁을 카드 위에 절대 배치 | 폰 폭에서 화면 37px 넘침 | 그래프 안쪽 + 세로 스택 |
| 루프 끝 되감기 | poster 가 빈 카드로 뽑힘 | 완성 96% 유지·복귀 98% |
그래프 아홉 개의 압축 비밀번호는 s6hc9dw3 입니다. 풀면 항목마다 브라우저에 바로 여는 vanilla 판과 컴포넌트로 붙이는 React 판이 짝으로 들어 있습니다.
접근성
그래프는 색만으로 값을 말하면 안 되므로 아홉 개 전부 role="img" 와 값이 요약된 aria-label 을 붙였고, 스크린리더용 데이터 표를 fixed 로 화면 밖에 두어 소리로는 전체 수치를 읽게 합니다. 눈금·범례·툴팁이 문자로 값을 다시 말하는 것도 같은 이유입니다. prefers-reduced-motion 에서는 미리보기 루프를 끄고 그래프가 다 그려진 최종 상태로 점프합니다 — 게이지(09)는 바늘이 68% 각도에 선 채로 멈춥니다.
FAQ
왜 Chart.js 대신 SVG 로 직접 그리나요?
빌드 결과물이 스크립트 없는 정적 파일이 되기 때문입니다. SVG 좌표 계산은 서버나 번들러 없이 브라우저에서 열리는 파일 하나로 끝나고, 애니메이션도 CSS 만으로 돕니다. 축 스케일 자동 계산이나 툴팁 정렬 같은 편의는 직접 구현해야 하지만, 아홉 개 모두 그 분량이 스케일 함수 두 개 안팎입니다(MDN SVG path 문서).
stroke-dashoffset 애니메이션은 성능에 무겁지 않나요?
한 번에 몇 개 안 되는 path 라면 잴 만합니다. 이 편의 루프는 12fps 로 24장을 뽑아 측정했는데 연속 프레임 사이 변화가 가장 큰 트리맵(08)도 캔버스의 23.879% 를 넘지 않았고, 히트맵(05)의 42칸 지연도 22/23 프레임 안에서 고르게 분산됩니다 — 누적 영역(07)의 33.087% 는 루프 전체의 누적면적이지 한 프레임의 값이 아닙니다. 다만 top·width 처럼 레이아웃을 다시 계산하는 속성과 섞으면 이야기가 달라지니, 그림을 움직일 때는 path 의 stroke 와 transform 만 씁니다.
반응형에서 그래프 크기는 어떻게 맞추나요?
viewBox 하나로 끝납니다. 좌표는 항목마다 정한 viewBox(폭 전체를 쓰는 그래프는 440) 단위로 계산하고 CSS 에서 width: 100%; height: auto 만 주면 컨테이너 폭을 따라 늘어납니다. 좁은 화면에서 라벨이 겹칠 때만 미디어쿼리로 글자 크기를 줄이거나 홀수 달처럼 건너뛰어 표시합니다 — 이 편의 320px 검증에서 아홉 개 전부 넘침 없이 들어갔습니다.