JavaScript

バブルチャートとは?読み方とD3.jsでの作り方(Pack Layout/散布図型)

バブルチャートは、1つの円(バブル)に3つの数値を載せて比較するグラフです。バブルグラフとも呼ばれます。横位置・縦位置・円の大きさがそれぞれ別の変数を担うため、散布図では表せない3つ目の指標を同じ平面に置けるのが特徴です。ただし読み手は円の大きさを面積で受け取ります。ここを外した実装は、数値の差を誇張したまま表示してしまいます。

この記事では、バブルチャートの3要素と散布図との違いを整理したうえで、D3.js v7での実装を2種類に分けて解説します。円を敷き詰めるPack Layout(円充填型)と、X軸・Y軸を持つ散布図型は、生成されるグラフもコードの構造も別物です。混同したまま片方のサンプルを流用すると、「円が1つも表示されない」「大きさがデータと合わない」という不具合に直結します。本文に載せた実測値は、すべてNode.js 26.5.0とd3 7.9.0(DOMはjsdom)で実行した出力です。

まとめ

  • バブルチャートの3要素はX位置・Y位置・円の面積です。面積が値に比例するため、半径をそのまま値に比例させると誇張になります
  • D3.jsのバブルチャートには、階層データを円で詰めるPack Layoutと、軸を持つ散布図型の2系統があります。Pack Layoutに軸はありません
  • Pack Layoutはd3.hierarchy().sum()d3.pack()の順に使い、node.xnode.ynode.rが自動で決まります。座標にも半径にもスケール関数は使いません
  • .sum()を呼ばずにpackへ渡すと半径も座標もNaNになり、円が1つも描画されません。表示されない不具合の最頻原因がこれです
  • 散布図型の半径にはd3.scaleSqrt()を使います。scaleLinearだと値4倍のときに面積が16倍になり、差が過大に見えます
  • D3 6.0でd3.eventが削除され、イベントリスナーは(event, d)を受け取る形に変わりました。v5以前のサンプルはそのままでは動きません

以下、読み方の前提から実装コード、つまずいたときの確認手順までを順に見ていきます。

バブルチャートの3要素と散布図との違い

バブルチャートは散布図の拡張です。散布図がX軸とY軸の2変数を扱うのに対し、バブルチャートは円の大きさを3つ目の変数に割り当てます。X軸に広告費、Y軸に売上、円の大きさに利益率を置けば、「広告費と売上の関係」と「その中でどの案件が儲かっているか」を1枚で示せます。

3要素のうち実装で最も事故が起きるのが円の大きさです。人は円を見たとき面積で量を判断します。値をそのまま半径に割り当てると、視覚上の差は二乗で開いてしまう。この性質はD3.jsに限った話ではなく、Excelでも他のBIツールでも共通します。

バブルの大きさを面積で読む理由

値が4倍のデータを、半径も4倍にして描いたとしましょう。円の面積は半径の二乗に比例するため、見た目の面積は16倍に膨らみます。読み手は面積で量を受け取るので、実際は4倍の差が16倍の差として伝わってしまう。バブルチャートの見方としてまず押さえるべき前提がここです。

d3のスケール関数で実測すると差は明確でした。最大値100を半径40に対応させた場合、値100と値25の半径は次のようになります。

スケール関数 値100の半径 値25の半径 面積比(値は4倍差)
d3.scaleLinear() 40.0 10.0 16.0倍
d3.scaleSqrt() 40.0 20.0 4.0倍

値の比と面積の比が一致するのはscaleSqrtのほうです。散布図型のバブルチャートを自分で組むなら、半径のスケールはscaleSqrtが既定と考えてください。Pack Layoutは半径の計算を内部で行うため、この選択自体が不要になります(後述)。

散布図・円グラフとの使い分けと向かないデータ

バブルチャートを選ぶ条件は2つです。3つ目の変数が「量」であること、そしてデータ点が数十件までに収まっていること。どちらかを外すと、別のグラフのほうが正確に伝わります。

  • 3つ目の変数が量ではなく分類(地域・カテゴリなど)なら、大きさではなく色で分ける。散布図+色分けで足ります
  • データ点が数百件を超えると円が重なり、個々の値が読めなくなる。ヒートマップや箱ひげ図が向きます
  • 構成比を示したいだけなら、円グラフや積み上げ棒グラフのほうが誤読が少ない
  • 時系列の推移が主題なら、バブルを並べるより折れ線グラフのほうが変化を追えます

数百件のデータで散布図型を組むと円同士が重なり、下に隠れた点は存在しないのと同じになります。件数が多いと分かっている場合は、重なりが原理的に発生しないPack Layoutを先に検討してください。

D3.jsのバブルチャートは2種類:Pack LayoutとX軸Y軸を持つ散布図型

「D3.js バブルチャート」で検索すると、性質の異なる2種類の実装が同じ名前で出てきます。この2つを取り違えたまま片方のコードを別方針に貼り付けるのが、動かない実装の典型パターンです。

観点 Pack Layout(円充填型) 散布図型
主なモジュール d3-hierarchy d3-scale / d3-selection
持たない X軸・Y軸を持つ
入力データ 階層構造(親子) フラットな配列
表せる変数 大きさ1つ+入れ子で分類 X・Y・大きさの3つ
座標の決定 d3.packが自動計算 xScale / yScaleで算出
半径の決定 d3.packが自動計算 scaleSqrtで算出
円の重なり 発生しない 発生する

表の中で決定的なのは「軸」の行です。Pack Layoutは円が重ならないように詰めるレイアウトアルゴリズムで、円の位置そのものに意味はありません。「言語別のシェア」「カテゴリ別の売上」のように、比較したい量が1つだけで、それを大小で見せたいときに使います。パックバブルチャートと呼ばれるのはこちらです。

一方、X軸とY軸に意味を持たせて3変数を同時に見せたいなら散布図型です。冒頭で挙げた「広告費×売上×利益率」はこちらに当たります。以降、それぞれの実装を順に見ていきましょう。D3.js自体のモジュール構成やv7での変更点はD3.js v7の基本設計とv6以前から刷新されたモジュール構成の全体像で扱っています。

Pack Layoutでパックバブルチャートを作る手順

Pack Layoutの処理は3段階です。データをd3.hierarchy()で階層オブジェクトに変換し、.sum()で各ノードの値を確定させ、d3.pack()に渡す。返ってきた各ノードにはxyrが入っているので、あとはSVGのcircleに流し込むだけです。

階層データの作成と.sum()の必須性

次のコードはd3 7.9.0で実行し、SVG要素の生成まで確認したものです。import行は公式が推奨するCDNのESMバンドルを指しています。Node.jsで動かす場合はnpm install d3したうえでimport * as d3 from "d3";に読み替えてください。

import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";

const data = {
  name: "root",
  children: [
    { name: "JavaScript", value: 1620 },
    { name: "Python", value: 1240 },
    { name: "Java", value: 880 },
    { name: "TypeScript", value: 640 },
    { name: "Go", value: 310 }
  ]
};

const width = 640, height = 640;

const root = d3.pack()
  .size([width, height])
  .padding(4)(
    d3.hierarchy(data)
      .sum(d => d.value)
      .sort((a, b) => b.value - a.value)
  );

const svg = d3.select("body").append("svg")
  .attr("viewBox", [0, 0, width, height])
  .attr("width", width)
  .attr("height", height);

const color = d3.scaleOrdinal(d3.schemeTableau10);

const node = svg.append("g").selectAll("g")
  .data(root.leaves())
  .join("g")
  .attr("transform", d => `translate(${d.x},${d.y})`);

node.append("circle")
  .attr("r", d => d.r)
  .attr("fill", d => color(d.data.name))
  .attr("fill-opacity", 0.75);

node.append("text")
  .attr("text-anchor", "middle")
  .attr("dy", "0.35em")
  .text(d => d.data.name);

このコードで得られた半径と座標は、値1620のJavaScriptがr=148.8(x=222.5, y=307.8)、値310のGoがr=65.1(x=219.5, y=90.0)でした。座標を計算するコードを1行も書いていない点に注目してください。d3.pack()node.xnode.ynode.rを割り当てる仕様で、公式ドキュメントも「assigning the following properties on root and its descendants: node.x / node.y / node.r」と明記しています。ここにscaleLinearscaleSqrtを挟む必要はありません。

半径の内訳も追えます。d3-hierarchyのソース(src/pack/index.js)では葉の既定半径がMath.sqrt(d.value)で、その後レイアウトサイズに収まるよう一律に拡大縮小される仕組みです。実測でも、値100・50・25・10の4ノードをsize([400, 400])padding(3)で描いたとき、r^2 / valueがいずれも131.284055で一致しました。paddingを0にすると137.258300、20にすると103.786994と定数自体は動きますが、どの条件でも全ノードで一定になります。面積が値に厳密比例するので、面積比例の調整を自分で入れると二重変換です。

そして最も重要なのが.sum()です。公式ドキュメントは「You must call root.sum before passing the hierarchy to the pack layout」と必須要件として書いています。実際に.sum()を外して実行した結果が次のとおりです。

const noSum = d3.hierarchy(data);
const packed = d3.pack().size([400, 400])(noSum);
packed.leaves().forEach(n => console.log(n.data.name, n.r, n.x, n.y, n.value));

// 実行結果(d3 7.9.0)
// A NaN NaN NaN undefined
// B NaN NaN NaN undefined
// C NaN NaN NaN undefined
// D NaN NaN NaN undefined

半径も座標もNaNになるため、circle要素は生成されても画面には何も映りません。コンソールにエラーも出ない。「円が1つも表示されない」という症状で最初に疑うべきはここです。

sizeとpaddingの効き方

.size([width, height])は既定が[1, 1]です。指定を忘れると全ノードの半径が1未満になり、実質見えない円が描かれます。.padding()の既定は0で、円と円の間隔を指定します。

同じデータ(値100・50・25・10)をsize([400, 400])で描いたとき、paddingの値ごとの半径は次のようになりました。

padding 最大の円の半径 2番目 3番目 4番目
0 117.16 82.84 58.58 37.05
3 114.58 81.02 57.29 36.23
20 101.88 72.04 50.94 32.22

paddingを広げるとレイアウトサイズに収めるため全体が縮み、円自体も小さくなります。公式ドキュメントはpaddingの効き方を「approximate」と説明しており、円を詰めてから縮尺を計算し直す2パス処理のため、指定値がそのまま画素の間隔になるわけではありません。ラベルを円の中に収めたいなら、paddingを増やすより文字サイズを半径から算出するほうが安定します。

入れ子の階層と親円を読むときの注意

Pack Layoutは2階層以上のデータも扱えます。グループを親ノード、実データを葉にすると、グループの円の中に個々の円が入った表現になります。

const nested = {
  name: "root",
  children: [
    { name: "G1", children: [{ name: "a", value: 30 }, { name: "b", value: 20 }] },
    { name: "G2", children: [{ name: "c", value: 40 }] }
  ]
};

const r = d3.pack().size([300, 300]).padding(4)(
  d3.hierarchy(nested).sum(d => d.value)
);
r.descendants().forEach(n => console.log(n.data.name, n.depth, n.r));

// 実行結果: root(depth0, r=150.0) G1(depth1, r=88.3) G2(depth1, r=56.3)
//           a(depth2, r=45.6) b(depth2, r=37.2) c(depth2, r=52.7)

注意が必要なのは親円のサイズです。G1の合計値は50、G2は40で1.25倍差ですが、半径は88.3と56.3で、面積の差はそれ以上に開いています。円を詰めた結果できる隙間が親円に含まれるためで、公式ドキュメントも「only the leaf nodes can be compared accurately」と、正確に比較できるのは葉だけだと明記しています。グループ間の合計を厳密に比べさせたいなら、親円の大きさに読み取りを委ねず、ラベルに数値を併記するかツリーマップを選んでください。

散布図型バブルチャートの実装

X軸・Y軸に意味を持たせる散布図型では、座標も半径も自分でスケール関数から求めます。Pack Layoutとはコードの構造がまったく異なります。

半径へのscaleSqrt割り当てと軸の描画

const data = [
  { x: 2, y: 30, v: 1620, name: "JavaScript" },
  { x: 5, y: 22, v: 1240, name: "Python" },
  { x: 9, y: 18, v: 880, name: "Java" },
  { x: 4, y: 26, v: 640, name: "TypeScript" },
  { x: 7, y: 12, v: 310, name: "Go" }
];

const svg = d3.select("body").append("svg")
  .attr("width", 640)
  .attr("height", 600);

const xScale = d3.scaleLinear().domain([0, 10]).range([60, 580]);
const yScale = d3.scaleLinear().domain([0, 35]).range([560, 40]);
const rScale = d3.scaleSqrt().domain([0, d3.max(data, d => d.v)]).range([0, 48]);

svg.append("g").attr("transform", "translate(0,560)").call(d3.axisBottom(xScale));
svg.append("g").attr("transform", "translate(60,0)").call(d3.axisLeft(yScale));

svg.append("g").selectAll("circle")
  .data(data.slice().sort((a, b) => b.v - a.v))
  .join("circle")
  .attr("cx", d => xScale(d.x))
  .attr("cy", d => yScale(d.y))
  .attr("r", d => rScale(d.v))
  .attr("fill", "steelblue")
  .attr("fill-opacity", 0.6)
  .attr("stroke", "#fff");

X軸とY軸にはscaleLinearを、半径にはscaleSqrtを使い分けます。上のデータで実測した半径は、値1620が48.0、値1240が42.0、値880が35.4、値310が21.0でした。yScaleのrangeを[560, 40]と逆順にしているのは、SVGのY座標が下向きに増えるためです。

.sort()で値の降順に並べてから描画しているのは、大きい円を先に描いて小さい円を上に重ねるためです。この一行が無いと、大きい円が小さい円を覆い隠してデータ点が消えます。fill-opacityと白いstrokeも重なりの識別に効きます。円が重ならないPack Layoutでは不要な配慮です。

ツールチップとホバー時の挙動

バブルの数値をその場で見せるにはツールチップを付けます。イベントリスナーの書き方はD3 6.0で変わっており、ここが古いサンプルとの最大の非互換点です。

const tooltip = d3.select("body").append("div")
  .style("position", "absolute")
  .style("visibility", "hidden")
  .style("background", "#fff")
  .style("border", "1px solid #ccc")
  .style("padding", "8px");

svg.selectAll("circle")
  .on("mouseover", (event, d) => {
    tooltip.style("visibility", "visible")
      .text(`${d.name}: ${d.v}`)
      .style("top", `${event.pageY + 12}px`)
      .style("left", `${event.pageX + 12}px`);
  })
  .on("mouseout", () => tooltip.style("visibility", "hidden"));

第1引数がevent、第2引数がデータです。D3 6.0のリリースノートは破壊的変更として「Remove d3.event!」「Change selection.on to pass the event directly to listeners.」を挙げており、v5以前のfunction(d) { ... d3.event.pageX ... }という書き方はv6以降で動きません。同時にd3.moused3.touchd3.touchesも削除され、座標取得はd3.pointer(複数点はd3.pointers)に統一されました。リスナーからインデックスと要素群の引数も外れています。

アロー関数を使う場合、thisがDOM要素を指さない点にも注意が要ります。ホバーした円自身を操作したいときはevent.currentTargetを使うか、function式で書いてください。

描画されない・大きさが合わないときの確認手順

バブルチャートの不具合は、切り分けの手順がほぼ決まっています。上から順に確認していくと原因にたどり着けます。

  • 円が1つも出ない。Pack Layoutなら.sum()の呼び忘れを疑う。console.log(root.leaves()[0].r)がNaNなら確定
  • 円が極端に小さい。.size()の指定漏れを疑う。既定は[1, 1]で半径も1未満になる
  • SVG自体が見えない。widthheight属性かCSSでのサイズ指定が無いかを確認する
  • 大きさがデータと合わない。半径のスケールがscaleSqrtかを確認する。scaleLinearだと差が二乗で誇張される
  • ホバーやクリックが反応しない。リスナーの引数が(event, d)になっているか、d3.eventが残っていないかを確認する
  • データが読めない。d3.json()d3.csv()はPromiseを返すためawait.then()で受ける。CSVは全フィールドが文字列で返るので数値化が要る

バージョン起因の不具合を減らすには、package.jsonでd3のバージョンを固定し、CDN利用時もURLにd3@7のようにメジャーバージョンを明示してください。読み込み方は2通りです。公式が推奨するのは+esm付きURLとtype="module"を組み合わせたESMバンドルですが、「we also provide a UMD bundle that exports the d3 global when loaded as a plain script」とあるとおり、<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>d3グローバルを生やすUMDバンドルも公式提供されています。既存ページへ後から埋め込むならUMDのほうが手数は少ないでしょう。

D3.jsを使うべきでない場合

バブルチャートを表示したいという要件だけなら、D3.jsは多くの場合で過剰です。D3.jsはチャートライブラリではなく、データを座標に変換する低レベルの部品群を指します。軸・凡例・ツールチップ・レスポンシブ対応は、必要なぶんだけ自分で書くことになる。ここまでのコードに凡例が一度も出てこないのは、それが標準では付いてこないからです。

次のいずれかに当てはまるなら、D3.jsは選ばないほうが工数に見合います。

逆にD3.jsを選ぶ理由がはっきりするのは、パックバブルチャートのように既製ライブラリの標準チャートに無い表現が要るとき、既存の描画に独自のインタラクションを差し込みたいとき、SVGの構造を細かく制御したいときです。要件が「円を大きさ順に詰めたい」ならd3-hierarchyの価値は高く、「軸付きの散布図に3変数目を足したい」だけなら他の選択肢を先に検討してください。

よくある質問

バブルチャートとは何ですか?

X位置・Y位置・円の大きさの3つで、1つのデータ点につき3種類の数値を同時に表すグラフです。散布図に「大きさ」という3つ目の変数を加えたものと考えると分かりやすいでしょう。円の大きさは面積で量を表すため、半径ではなく面積が値に比例するように描くのが前提になります。

バブルチャートと散布図の違いは何ですか?

扱える変数の数が違います。散布図はX軸とY軸の2変数で、バブルチャートは円の大きさを加えた3変数を表現できます。その代わり円が重なると個々の点が読めなくなるため、扱えるデータ件数は散布図より少なめです。

バブルチャートはどんな時に使いますか?

2つの指標の関係を見ながら、3つ目の量的な指標で優先度を判断したいときです。広告費と売上の関係の中で案件規模を示す、市場成長率とシェアの中で売上規模を示すといった用途が典型でしょう。3つ目が分類データの場合や、データ点が数百件を超える場合には向きません。

Excel(エクセル)でバブルチャートを作れますか?

作れます。Microsoftの公式サポートによると、データは「最初の行または列に x 値を一覧表示し、隣接する行または列の対応する y 値とバブル サイズ (z) の値を一覧表示します」という並びにします。範囲を選んだら挿入タブのグラフグループで散布図の横の矢印をクリックし、バブルチャートを選ぶ流れです。ワークシートには4行または4列以上のデータが必要で、3行/列未満だとバブルが正しくプロットされません。

パックバブルチャートとは何ですか?

軸を持たず、円を重ならないように詰めて大きさだけで量を比較する形式です。D3.jsではd3.pack()で作れます。階層データを入れると親子が入れ子の円になりますが、隙間が親円に含まれるため、正確に比較できるのは最下層の円だけです。

D3.jsとは何ですか?

データをSVGなどのDOM要素に対応づけるためのJavaScriptライブラリです。スケール変換・軸・レイアウトアルゴリズム・遷移といった部品がモジュール単位で提供され、この記事で使ったd3-hierarchyもその1つにあたります。完成したグラフを返すのではなく描画の部品を提供する設計のため、表現の自由度は高い反面、記述量は高レベルのチャートライブラリより増えます。

D3.jsのバージョンはどれを使えばよいですか?

新規実装なら現行のv7系です。2026年8月時点でnpmのlatestは7.9.0(公開日は2024年3月12日)、d3-hierarchyは3.1.2で、v8はまだ出ていません。更新が止まったように見えますが、v7が現行の安定版です。v5以前のサンプルコードを流用する際は、6.0でd3.eventd3.moused3.touchが削除され、イベントリスナーが(event, d)を受け取る形に変わった点を確認してください。

関連記事

資料請求

RELATED POSTS 関連記事