GODRICH

css グラフ をSVGで描く9種 — ライブラリなし

css グラフとは、数値を線・棒・面という目に見えるかたちに変える、いちばん軽い方法のことです。ここにある9種(折れ線・縦棒・ドーナツ・レーダー・ヒートマップ・スパークライン・積み上げ面・ツリーマップ・ゲージ)は、ライブラリなしでSVGの座標計算とCSSアニメーションだけで描いたものです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9種の並び順は、グラフが絵を完成させていく順です。まず線を引く3つ — x・y計算でpathをつなぎ(01)、棒が下辺から伸び(02)、円弧の区画が順に満ちます(03)。次に面を塗る3つ — 中心から多角形が広がり(04)、格子の区画が濃度で染まり(05)、積み上げ面が下から積み上がります(07)。最後の3つは場所を分けて値を指す側で、カードの中の短い推移(06)、面積で分割されたツリーマップ(08)、値ひとつを角度に変えるゲージ(09)です。舞台色はインク3・紙3・黄2・橙1で回して、9分割の明るさが片側に偏らないように揃えました。描き終えたグラフをマウスで操作する側はダッシュボード チャート操作9種に、輪ひとつで数値を数える側は円形プログレスバーのスタットリング9種にあります。

01ペンのように描かれる折れ線グラフ

12か月の訪問数をscaleXscaleYの2関数でviewBox座標に変えて1本のpathにつなぎ、pathLength=100を付けてstroke-dashoffsetを100から0へ下げると、左から端までペンで引いたように描かれます。折れる点ごとに隣接点の中点をつなぎ、実際の点は制御点として使う滑らかな線なので、折れが残りながら波打ちません。ペンが端まで線を引いていくループで累積面積25.276%、動いたフレーム18/23を記録し、月次トラフィックレポートの要約や管理画面の最初のカードに使います。

stroke-dashoffsetpathLengthpointermove
// 스케일 함수 — 데이터 값 → 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下から伸びる縦棒グラフ

週間売上を棒1本にセル1つでまとめ、--d遅延で順に立ち上げ、transform: scaleYtransform-origin: 50% 100%と組み合わせて下辺の軸から上へ伸ばします — 基準線に触れる下側が原点でなければ、伸びるのではなく引き伸ばされて見えます。値ラベルは途中の値と最終値の2枚をカットで切り替え、最も高い棒(土曜)だけシリーズの青で強調します。ループで累積面積14.742%、強度166.4を記録し、今週の最高値を強調する週間売上のまとめカードに使います。

transform-originscaleYanimation-delay
// 데이터 값 → 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コマンド4本で計算し、各区画にpathLength=100を付けるので、stroke-dashoffsetは円弧の長さと無関係に0〜100で動きます。区画間の2pxのすき間は、pathをその角度ぶん縮めて描いた結果で、クリーム背景が自然な仕切りになります。次々と続く筆致が2秒のループを満たし、累積面積7.767%、強度169.0を記録し、構成比をひと目で見せる流入チャネルのカードに使います。

stroke-dasharraystroke-dashoffsetanimation-delay
// 시작·끝 각도(라디안)로 원호 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中心から広がるレーダーチャート

6軸のスコアをpos(i, r)の三角関数座標に置くので、目盛りの六角リングもデータの多角形も同じ関数から生まれます。多角形はtransform: scaleで中心から広がりますが、SVG要素のCSS transformの基準ボックスはデフォルトでviewBox原点なので、transform-box: fill-boxtransform-origin: centerを添えて初めて、自分の中央から大きくなります。多角形の拡大に続いて頂点の点6つが順にポップするループで累積面積5.192%、動いたフレーム19/23を記録し、アンケート比較や機能満足度のカードに使います。

pointstransform-originscale
// 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区画は色を1つに固定し、fill-opacityだけを濃度関数0.12 + 0.88 × v / 100で動かします。区画ごとにanimation-delayを28ms間隔で付けて左から右へ、1行が終われば次の行へと順に染めますが、遅延のあるループにはanimation-fill-mode: backwardsが必須で、なければ完成状態が最初のフレームに一瞬ちらつきます。42区画がすべて染まるループで累積面積29.057%、動いたフレーム22/23を記録し、訪問時間帯の密度パネルや活動レポートに使います。

animation-delayanimation-fill-modefill
// 농도 함수 — 이 데모의 주제. 색은 한 가지로 고정하고 투명도만 값에 비례해 움직인다
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に縮め、終点にはラインと同じシリーズ色のピリオドを打ちます。大きな数字2枚(途中の値と最終値)を同じgridのマスに重ね、steps(1, end)のカットで切り替えるので、数字が変わっても位置が揺れません。ラインの描画・点のポップ・数字のカット交代が続くループで強度128.8を記録し、KPIカードの中のミニ推移や一覧の行の補助指標に使います。

pathLengthstroke-dasharrayr
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下から満ちる積み上げ面グラフ

3系列の占有率を毎月100になるように足し合わせ、積み上げの境界線4本(床・デスクトップの上・モバイルの上・天井)を求め、各層のpathは上の境界を左から右へ歩き、下の境界を逆に戻って面を閉じます。プレビューは同じpathにclip-path: insetのワイプを重ね、下の層から順に満ち上がります。3層が順に満ちるループで累積面積33.087%、強度160.0を記録し、端末・ブラウザ占有率の推移パネルに使います。

clip-pathopacitytransform-origin
// 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大きい順に座るツリーマップ

カテゴリ売上を値の降順に並べ、残った領域を重さの半分にいちばん近い点で2分割し、縦横比が1に近づく方向へ再帰する簡易squarifyで配置します。ラベルは面積が十分な区画だけに刻み、小さい区画の値はホバーのツールチップとスクリーンリーダーの表が担います。大きい区画から順に現れるループで累積面積53.981%を記録し、カテゴリ売上の構成パネルやポートフォリオの内訳ビューに使います。

fill-opacitypointermoveviewBox
// 간이 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要素のCSStransform-originはviewBox座標系で解釈されるので、ゲージの中心(220px 126px)を直接指定します。針が0から68まで回って弧が満ちるループで強度141.5を記録し、目標達成率の表示や割り当て進捗のカードに使います。

rotatestroke-dasharraytransform-origin
// 값 → 바늘 회전각(도). 바늘 기본 모양이 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)];
}

どこで壊れるか — 落とし穴

最初の落とし穴は、2つの角度の規約を1つの式に混ぜることです。SVGのy軸は下に伸びるので、数学的な角度から座標を取るときはsinを引かなければ上側の半円になりません。この編の09では最初、CSS回転の感覚でCY + radius * Math.sin(rad)と書いてしまい、半円がまるごと下に裏返りました。区画60〜80%の中心角54度・半径88では、誤ったyは197.2 — viewBoxの高さ176を21.2も超えて、ホバーのツールチップの引き綱が下辺より下に沈みました。sinを引く1行で54.8に戻りました(根拠: run/289/_수리전실측.json)。針のCSSrotateは時計回り、数学の角度は反時計回りで、2つの値はもともと別の数字だという点もあわせて書き留めておきます。2つ目は、ツールチップをカードの上に絶対配置すること — 07で1行に3系列を並べたツールチップは、スマホの幅で37pxもはみ出し、プロットの内側に置いて縦に積むことで320pxに収まりました。3つ目は、巻き戻しの瞬間が代表画像を奪うこと — ループの終わりにグラフを消す場面の変化面積が登場より大きいとposterが空のカードになるので、完成状態は96%まで保ち、巻き戻しは98%に遅らせてサンプルの外へ出します。

落とし穴 症状 直し方
sinの符号をCSSの感覚で 半円が下に裏返る yは下に伸びるのでCY - r*sin(rad)
ツールチップをカードの上に配置 スマホ幅で37pxはみ出す プロット内側に縦積み
ループ終端の巻き戻し posterが空のカードになる 96%まで保持・98%で復帰

グラフ9個のソースはzipにまとめてあり、開くためのパスワードは s6hc9dw3 です。展開すると、項目ごとにブラウザでそのまま開くvanilla版とコンポーネントとして貼るReact版が対で入っています。

アクセシビリティ

色だけが値を語るグラフは読めないので、9個すべてにrole="img"と値を要約したaria-labelを付け、画面の外にfixedで置いたスクリーンリーダー用のデータ表で全体の数値を声で読めるようにしました。目盛り・凡例・ツールチップが文字で値を繰り返すのも同じ理由です。prefers-reduced-motionではプレビューのループを止めて、描き終えた最終状態へジャンプします — ゲージ(09)は針が68%の角度で立ったまま止まります。

FAQ

なぜChart.jsではなくSVGで直接描くのですか?

ビルド結果がスクリプトに依存しない静的ファイルになるからです。SVGの座標計算はブラウザで開くファイル1つで完結し、アニメーションまで純粋なCSSで動きます。軸スケールの自動計算やツールチップの整列は自分で書く必要がありますが、この9種が示すように、その量はスケール関数2つ程度です(MDN SVG path属性)。

stroke-dashoffsetアニメーションは重くありませんか?

数本のpathなら気になる重さではありません。この編のループを12fpsで24枚に撮って測ると、いちばん忙しいツリーマップ(08)も連続するフレーム間の変化はキャンバスの23.879%までで、ヒートマップの42区画の遅延も22/23フレームに均等に散らばっています。重くなるのはtopwidthのようにレイアウトを再計算させる属性を混ぜたときで、グラフを動かすのはstrokeとtransformだけにします。

レスポンシブではグラフの大きさはどう合わせますか?

viewBox1つで済みます。座標は項目ごとに決めたviewBox(全幅のグラフは440)単位で計算し、CSSでwidth: 100%; height: autoとだけ指定すれば、コンテナの幅に合わせて絵全体が伸び縮みします。狭い画面でラベルが重なるときだけメディアクエリで文字を縮めるか、奇数月のように間引いて表示します — この編の320px検証では、9個すべてがはみ出しなく収まりました。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。