GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

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

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

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

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

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

何が見えるか

12か月の訪問数をscaleX・scaleYの2関数でviewBox座標に変えて1本のpathにつなぎ、pathLength=100を付けてstroke-dashoffsetを100から0へ下げると、左から端までペンで引いたように描かれます。折れる点ごとに隣接点の中点をつなぎ、実際の点は制御点として使う滑らかな線なので、折れが残りながら波打ちません。

ペンのように描かれる折れ線グラフ
新しいタブで開く

01 · ペンのように描かれる折れ線グラフ — 計測 累積面積 25.276% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

stroke-dashoffsetpathLengthpointermove

コードのどこを見るか

ペンが端まで線を引いていくループで累積面積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下から伸びる縦棒グラフ

css グラフ アニメーション はどこから始まるか

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

下から伸びる縦棒グラフ
新しいタブで開く

02 · 下から伸びる縦棒グラフ — 計測 累積面積 14.688% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transform-originscaleYanimation-delay

コードの読み方

ループで累積面積14.742%、強度166.4を記録し、今週の最高値を強調する週間売上のまとめカードに使います。

// データ値 → viewBox 座標。棒グラフの核心はこの二つの関数
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }
function band() { return (PAD_R - PAD_L) / DATA.length; }          // 棒1本が占める幅
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をその角度ぶん縮めて描いた結果で、クリーム背景が自然な仕切りになります。

一区画ずつ満ちるドーナツグラフ
新しいタブで開く

03 · 一区画ずつ満ちるドーナツグラフ — 計測 累積面積 7.771% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

stroke-dasharraystroke-dashoffsetanimation-delay

どこを変えるか

次々と続く筆致が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中心から広がるレーダーチャート

何が見えるか

6軸のスコアをpos(i, r)の三角関数座標に置くので、目盛りの六角リングもデータの多角形も同じ関数から生まれます。多角形はtransform: scaleで中心から広がりますが、SVG要素のCSS transformの基準ボックスはデフォルトでviewBox原点なので、transform-box: fill-boxとtransform-origin: centerを添えて初めて、自分の中央から大きくなります。

中心から広がるレーダーチャート
新しいタブで開く

04 · 中心から広がるレーダーチャート — 計測 累積面積 5.192% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointstransform-originscale

コードのどこを見るか

多角形の拡大に続いて頂点の点6つが順にポップするループで累積面積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区画は色を1つに固定し、fill-opacityだけを濃度関数0.12 + 0.88 × v / 100で動かします。区画ごとにanimation-delayを28ms間隔で付けて左から右へ、1行が終われば次の行へと順に染めますが、遅延のあるループにはanimation-fill-mode: backwardsが必須で、なければ完成状態が最初のフレームに一瞬ちらつきます。

順番に染まるヒートマップ
新しいタブで開く

05 · 順番に染まるヒートマップ — 計測 累積面積 29.057% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

animation-delayanimation-fill-modefill

コードの読み方

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に縮め、終点にはラインと同じシリーズ色のピリオドを打ちます。大きな数字2枚(途中の値と最終値)を同じgridのマスに重ね、steps(1, end)のカットで切り替えるので、数字が変わっても位置が揺れません。

カードの中のスパークライン
新しいタブで開く

06 · カードの中のスパークライン — 計測 累積面積 3.914% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pathLengthstroke-dasharrayr

どこを変えるか

ラインの描画・点のポップ・数字のカット交代が続くループで強度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下から満ちる積み上げ面グラフ

何が見えるか

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

下から満ちる積み上げ面グラフ
新しいタブで開く

07 · 下から満ちる積み上げ面グラフ — 計測 累積面積 33.087% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clip-pathopacitytransform-origin

コードのどこを見るか

3層が順に満ちるループで累積面積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大きい順に座るツリーマップ

画面で起きること

カテゴリ売上を値の降順に並べ、残った領域を重さの半分にいちばん近い点で2分割し、縦横比が1に近づく方向へ再帰する簡易squarifyで配置します。ラベルは面積が十分な区画だけに刻み、小さい区画の値はホバーのツールチップとスクリーンリーダーの表が担います。

大きい順に座るツリーマップ
新しいタブで開く

08 · 大きい順に座るツリーマップ — 計測 累積面積 53.984% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fill-opacitypointermoveviewBox

コードの読み方

大きい区画から順に現れるループで累積面積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要素のCSStransform-originはviewBox座標系で解釈されるので、ゲージの中心(220px 126px)を直接指定します。

数値で針が動く半円ゲージ
新しいタブで開く

09 · 数値で針が動く半円ゲージ — 計測 累積面積 7.21% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotatestroke-dasharraytransform-origin

どこを変えるか

針が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)];
}

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

最初の落とし穴は、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%で復帰

描いたpathを画面上で人に直させるなら、html css エディタ UIの編集部品がその席を受け取ります。グラフ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フレームに均等に散らばっています。重くなるのはtopやwidthのようにレイアウトを再計算させる属性を混ぜたときで、グラフを動かすのはstrokeとtransformだけにします。

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

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

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

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