バブルチャートは「3 軸を 1 枚にたたみ込む」最終兵器
棒グラフ、折れ線、散布図と続けてきたこの連載も Part 16。データ可視化を続けていると必ず一度はぶつかる壁がある。「軸が足りない」問題だ。
たとえば商業販売額のランキングを作ろうと思ったら、すぐに気になることがある。「東京の販売額がデカいのは当たり前じゃないか? 人口が多いんだから」「いや、人口当たりで割ったら鳥取の方が高いんじゃないの?」「従業者数で割るとどうなる?」。こうした疑問に答えるには 1 つの棒グラフでは足りない。少なくとも 3 つの数字を同時に見せる必要がある。
ここで登場するのがバブルチャートだ。x 軸、y 軸、そして円の半径(実質「面積」)の 3 軸で 3 つの変数を同時にエンコードできる。色を加えれば 4 軸、形状を加えれば 5 軸まで拡張可能だが、人間の認識が破綻するので 3 〜 4 軸が現実的な上限だと思っておこう。
この記事では商業統計調査の販売額・従業者数と人口推計を組み合わせ、47 都道府県を 3 軸で可視化する。Claude Code を使えば 3 つの統計表を取ってきて結合し、D3 でバブルチャートを描き、ラベル衝突を回避するところまで一気通貫で実装できる。Part 6 で散布図を扱ったが、今回はそれを次元拡張したバージョンと考えてくれればいい。
上のサンプルチャートでは横軸に人口、縦軸に商業販売額(億円)、円の大きさを商業従業者数で表現している。東京の巨大バブルが右上に飛び抜けているのは想像通り。だが面白いのは沖縄や福井のように「人口の割に販売額が多い/少ない」県が y = x の対角線から外れて見えてくることだ。バブルチャートはこういう「比率の外れ値」を直感的に発見させてくれる。
ちなみにバブルチャートの祖と言えば Hans Rosling の Gapminder。あれが衝撃的だったのは、200 カ国の所得・寿命・人口を 1 枚のチャートに動的に詰め込んで「世界の見え方」を変えてしまったからだ。今回作るのはあの 47 都道府県版である。
使うデータ: 商業統計 + 人口推計の合わせ技
まずデータソースの確認から。商業販売額のデータは経済産業省が出している「商業動態統計」と総務省統計局の「経済センサス活動調査」のどちらでも取れるが、都道府県別の細かい数字は経済センサスの方が網羅性が高い。今回は以下の 3 つの統計表を使う。
| 統計名 | 取得内容 | e-Stat statsDataId(例) |
|---|---|---|
| 経済センサス活動調査(卸売業・小売業) | 都道府県別 年間商品販売額 | 0003263013 |
| 経済センサス活動調査(卸売業・小売業) | 都道府県別 従業者数 | 0003263012 |
| 人口推計 | 都道府県別 総人口 | 0003448237 |
statsDataId は時点や調査ラウンドで変わるので、実際には /search-estat スキルで「商業販売額 都道府県」のような検索を最初にかけてからメタ情報を確認するのが鉄則。Part 2 でやった検索スキルがここで効いてくる。
データの粒度を揃えるため、今回は 2021 年(経済センサス活動調査 2021)の数字に統一する。商業統計のデータは年次更新ではなくセンサス系(5 年に 1 度)が多いので、最新年が偏ることに注意。人口推計だけが毎年更新されるので、合わせる年を間違えると「分母だけ最新、分子は 4 年前」というキメラデータになる。
なお、商業販売額には「卸売」と「小売」がある。今回は卸売 + 小売の合計値(年間商品販売額)を使う。卸売だけ取ると東京・大阪・愛知に超偏重するため、小売を含めることで地方のバブルも見えるバランスになる。
Step 1: 3 つの統計表を Claude Code に取らせる
ここからが本題。3 つの統計表を順に取得していく。Claude Code に投げるプロンプトは次のような感じで十分動く。
claude "e-Stat API から以下 3 つの統計表を取得して JSON で /tmp/raw/ に保存して:
1. 経済センサス活動調査 卸売業・小売業 都道府県別 年間商品販売額(2021年)
2. 同 都道府県別 従業者数
3. 人口推計 都道府県別 総人口(2021年10月1日現在)
すべて 47 都道府県分が揃うこと。レスポンスから VALUE 配列だけ抽出して
prefCode(5桁)と value を持つ配列にして。"
Claude Code は内部で /fetch-estat-data 系のスキルを使って 3 回 API を叩き、JSON を吐く。注意点として、e-Stat API は時点指定をしないと全年度が返ってくるため、.claude/rules/estat-api.md のルール通り cdTime パラメータは投げず、全部取ってからメモリ上で年度をフィルタするのが正しい使い方。キャッシュヒット率が段違いに上がる。
取得後、/tmp/raw/commerce_sales_2021.json には次のような形のデータが入っているはず。
[
{ "prefCode": "01000", "prefName": "北海道", "value": 12030456 },
{ "prefCode": "02000", "prefName": "青森県", "value": 1789234 },
{ "prefCode": "03000", "prefName": "岩手県", "value": 1956734 },
{ "prefCode": "13000", "prefName": "東京都", "value": 175934567 },
{ "prefCode": "27000", "prefName": "大阪府", "value": 48923456 },
{ "prefCode": "47000", "prefName": "沖縄県", "value": 1234567 }
]
単位は百万円。東京の 175,934,567 百万円というのは約 175 兆円。日本の卸売 + 小売販売額の合計が約 480 兆円なので、東京 1 都で 36% を持っていることになる。これはあとでバブルチャートを描くと一目で分かる。
従業者数 JSON はもっとシンプルで、value が人数(人)。人口推計は value が千人単位なので、後で 1000 倍するのを忘れないように。単位の食い違いはバブルチャートの「半径を radius にして実は面積で何倍にもなっていた」事故と並ぶ古典的バグなので、最初に単位を unit フィールドで明示しておくと事故が減る。
Step 2: 都道府県コードで 3 つを結合
データが揃ったら、prefCode をキーに結合する。Node.js でやるならこんな感じ。
// /tmp/merge-bubble-data.js
const fs = require("node:fs");
const PREF_NAMES = {
"01000": "北海道",
"02000": "青森県",
// ... 47 件
"47000": "沖縄県",
};
const sales = JSON.parse(fs.readFileSync("/tmp/raw/commerce_sales_2021.json"));
const workers = JSON.parse(fs.readFileSync("/tmp/raw/commerce_workers_2021.json"));
const population = JSON.parse(fs.readFileSync("/tmp/raw/population_2021.json"));
const toMap = (rows) => Object.fromEntries(rows.map((r) => [r.prefCode, r.value]));
const salesMap = toMap(sales);
const workersMap = toMap(workers);
const popMap = toMap(population);
const merged = Object.keys(PREF_NAMES).map((code) => ({
prefCode: code,
prefName: PREF_NAMES[code],
// 単位を全部基本単位に揃える
salesYen: (salesMap[code] ?? 0) * 1_000_000, // 百万円 → 円
workers: workersMap[code] ?? 0, // 人
population: (popMap[code] ?? 0) * 1000, // 千人 → 人
}));
// 派生指標も計算しておく
const enriched = merged.map((d) => ({
...d,
salesPerCapita: d.population > 0 ? d.salesYen / d.population : 0,
salesPerWorker: d.workers > 0 ? d.salesYen / d.workers : 0,
}));
fs.writeFileSync("/tmp/bubble-data.json", JSON.stringify(enriched, null, 2));
console.log(`merged ${enriched.length} prefectures`);
「単位を全部基本単位に揃える」のがポイント。バブルチャートでは半径計算で Math.sqrt(value) を使うので、桁が混ざっていると半径計算がカオスになる。早めに「すべて円」「すべて人」と単位を統一しておく。
これを実行すると /tmp/bubble-data.json に 47 件のレコードができる。中身はこういう形。
[
{
"prefCode": "13000",
"prefName": "東京都",
"salesYen": 175934567000000,
"workers": 1234567,
"population": 14000000,
"salesPerCapita": 12566755,
"salesPerWorker": 142500000
},
{
"prefCode": "31000",
"prefName": "鳥取県",
"salesYen": 980000000000,
"workers": 56789,
"population": 553000,
"salesPerCapita": 1772152,
"salesPerWorker": 17256000
}
]
東京の「1 人当たり販売額」が 1,256 万円というのは「都民 1 人が年に 1,256 万円分の商品を東京の商店で買っている」という意味ではなく、卸売を含むため周辺県の事業所が東京の卸から仕入れている分も入っている、と解釈する。だから「東京は儲かってる」じゃなくて「東京は流通のハブ」と読むのが正しい。
これで描画用データが揃った。
Step 3: D3 でバブルを描く(d3.scaleSqrt が半径計算の正解)
ここから描画フェーズ。SVG を描く基本骨格は次の通り。
// /tmp/bubble-chart.js (Node でレンダリング → SVG 出力)
const d3 = require("d3");
const { JSDOM } = require("jsdom");
const fs = require("node:fs");
const data = JSON.parse(fs.readFileSync("/tmp/bubble-data.json"));
const W = 960;
const H = 600;
const M = { top: 40, right: 40, bottom: 60, left: 80 };
const innerW = W - M.left - M.right;
const innerH = H - M.top - M.bottom;
const dom = new JSDOM("<!DOCTYPE html><body></body>");
const body = d3.select(dom.window.document.body);
const svg = body.append("svg")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("viewBox", `0 0 ${W} ${H}`)
.attr("width", W).attr("height", H);
const g = svg.append("g").attr("transform", `translate(${M.left},${M.top})`);
// スケール定義
const x = d3.scaleLog()
.domain([d3.min(data, (d) => d.population) * 0.9, d3.max(data, (d) => d.population) * 1.1])
.range([0, innerW]);
const y = d3.scaleLog()
.domain([d3.min(data, (d) => d.salesYen) * 0.9, d3.max(data, (d) => d.salesYen) * 1.1])
.range([innerH, 0]);
// 半径は scaleSqrt で「面積比例」にする
const r = d3.scaleSqrt()
.domain([0, d3.max(data, (d) => d.workers)])
.range([0, 50]); // 最大半径 50px
// 軸
g.append("g").attr("transform", `translate(0,${innerH})`)
.call(d3.axisBottom(x).ticks(6, "~s"));
g.append("g").call(d3.axisLeft(y).ticks(6, "~s"));
// バブル
g.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", (d) => x(d.population))
.attr("cy", (d) => y(d.salesYen))
.attr("r", (d) => r(d.workers))
.attr("fill", "#2563eb")
.attr("fill-opacity", 0.45)
.attr("stroke", "#1e40af")
.attr("stroke-width", 1);
// ラベル(暫定。次の Step で衝突回避する)
g.selectAll("text.label")
.data(data)
.join("text")
.attr("class", "label")
.attr("x", (d) => x(d.population))
.attr("y", (d) => y(d.salesYen))
.attr("text-anchor", "middle")
.attr("dy", "0.3em")
.attr("font-size", 10)
.text((d) => d.prefName);
fs.writeFileSync("/tmp/bubble.svg", body.html());
console.log("wrote /tmp/bubble.svg");
ここで一番大事なのは d3.scaleSqrt() を使っているところ。多くの人は半径を直接 value に比例させようとする(scaleLinear)が、これは間違い。
なぜか。人間の目は円の「半径」ではなく「面積」で大きさを認識する。半径を 2 倍にすると面積は 4 倍になる。だから value を半径に直接マップすると、見た目の差が実際の値の 2 乗で誇張される。scaleSqrt を使うことで「value が 4 倍 → 半径が 2 倍 → 面積が 4 倍」と認識通りの比例関係になる。
これは可視化の世界で「Apple の円グラフ問題」とか「USA Today バブル事件」とか呼ばれる古典的な失敗で、r = scaleLinear(value) で描いた瞬間に分析の信頼が地に落ちる。Claude Code に書かせるときも「半径は scaleSqrt で面積比例にしてくれ」と一言添えるか、レビュー段階で必ずチェックする。
Step 4: 軸スケールは対数 or 線形?
次に悩むのが軸スケール。商業販売額は東京の 175 兆円から鳥取の 1 兆円弱まで 200 倍近い幅がある。これを線形軸で描くと、東京以外の 46 県が左下のスパゲッティ団子になって判別不能になる。
| 軸の選択 | メリット | デメリット |
|---|---|---|
| 線形軸 | 「絶対量の差」が直感的 | 上位 1 〜 2 県だけで占有、他が潰れる |
| 対数軸 | 47 県が均等にばらける | 倍率の差が見えにくい・0 値が描けない |
今回は「分布を見たい」目的なので両軸とも d3.scaleLog() を採用。これで東京・大阪・愛知の御三家と、地方の県がほぼ等間隔で並んでくれる。
対数軸の罠は 1 つだけ。値に 0 や負数があると log(0) = -Inf で爆死する。47 都道府県の販売額・人口・従業者数は基本ゼロにならないが、市区町村別データを扱う場合は要注意。Math.max(value, 1) で下限を切るか、データソース段階で「値が 0 の県は除外する」かを決めておく。
x が人口、y が販売額の場合、対角線(y = ax の傾き)が「1 人当たり販売額」を表現する。対角線の上にいる県は人口の割に販売が多く、下にいる県はその逆。これがバブルチャートを散布図的に読むときの基本パターン。Part 6 で散布図を扱ったときも同じ読み方をしたが、バブルチャートでは更にバブルの大きさで「事業所規模感」が加わる。
Step 5: ラベル衝突回避(d3-force vs annealing)
47 都道府県のラベルをそのまま打つと、首都圏で 5 個くらい重なって読めなくなる。これを解決する手法は大きく 2 つ。
手法 A: d3.forceSimulation でラベルを押し合いへし合いさせる
d3-force を使うと「ラベル同士が衝突したら反発する」物理シミュレーションが書ける。バブル本体は固定して、ラベルだけが動くようにする。
const labels = data.map((d) => ({
...d,
x: x(d.population),
y: y(d.salesYen),
fx: x(d.population), // バブル本体は固定したいので fx, fy で固定
fy: y(d.salesYen),
}));
const labelNodes = data.map((d) => ({
prefName: d.prefName,
x: x(d.population),
y: y(d.salesYen) - r(d.workers) - 8, // バブルの上に初期配置
targetX: x(d.population),
targetY: y(d.salesYen),
}));
const sim = d3.forceSimulation(labelNodes)
.force("collide", d3.forceCollide().radius(18))
.force("x", d3.forceX((d) => d.targetX).strength(0.3))
.force("y", d3.forceY((d) => d.targetY - 20).strength(0.3))
.stop();
for (let i = 0; i < 200; i++) sim.tick();
g.selectAll("text.label")
.data(labelNodes)
.join("text")
.attr("class", "label")
.attr("x", (d) => d.x)
.attr("y", (d) => d.y)
.attr("text-anchor", "middle")
.attr("font-size", 10)
.text((d) => d.prefName);
// バブルからラベルまでの引き出し線
g.selectAll("line.leader")
.data(labelNodes)
.join("line")
.attr("class", "leader")
.attr("x1", (d) => d.targetX)
.attr("y1", (d) => d.targetY)
.attr("x2", (d) => d.x)
.attr("y2", (d) => d.y)
.attr("stroke", "#94a3b8")
.attr("stroke-width", 0.5);
forceCollide の半径を 18 ピクセルにしておくと、ラベルがそこそこ離れた状態に落ち着く。forceX forceY で「本来の位置に戻ろうとする力」を与え、forceCollide で「重なったら反発」させることでバランスを取る。
手法 B: simulated annealing で配置を最適化
もう少し丁寧にやるなら、ラベル位置を「8 方向」のうちどれに置くかを simulated annealing で探索する手法もある。d3-labeler というプラグインが有名(オリジナルは Evan Wang の論文実装)。47 ラベル程度なら手法 A で十分だが、100 件以上のスキャタープロットで使うときは d3-labeler の方が綺麗にまとまる。
手法 C: 主要県のみラベル表示
そして 3 つめは「諦めて主要県だけラベルを出す」。実際これが一番読みやすかったりする。
const TOP_LABELS = ["東京都", "大阪府", "愛知県", "神奈川県", "福岡県", "北海道", "沖縄県", "鳥取県"];
const visibleLabels = data.filter((d) => TOP_LABELS.includes(d.prefName));
外れ値や上位下位だけラベル、それ以外は hover tooltip に逃がす、というのが実務的にはバランス良い。
Step 6: tooltip と凡例(半径の意味)
最後に仕上げ。バブルチャートで絶対に忘れちゃいけないのが「半径の凡例」。x 軸と y 軸はティックで意味が分かるが、半径だけはユーザーに「これが何を意味するのか」を必ず示さないと、見た目だけインパクトのあるグラフになって解釈不能になる。
凡例は右下や左上に「半径 = 従業者数」のサンプル円を 2 〜 3 個並べるのが定番。
const legendValues = [100_000, 500_000, 1_000_000];
const legend = svg.append("g")
.attr("transform", `translate(${W - 200},${H - 120})`);
legend.append("text").text("半径 = 従業者数(人)").attr("font-size", 11);
legendValues.forEach((v, i) => {
legend.append("circle")
.attr("cx", 25)
.attr("cy", 20 + i * 35)
.attr("r", r(v))
.attr("fill", "none")
.attr("stroke", "#475569");
legend.append("text")
.attr("x", 60)
.attr("y", 20 + i * 35)
.attr("dy", "0.35em")
.attr("font-size", 10)
.text(v.toLocaleString() + " 人");
});
tooltip はクライアント側で SVG にイベントを付ければいい。Next.js なら onMouseEnter でステートを更新して別の div に「東京都: 販売額 175 兆円 / 従業者 123 万人 / 人口 1,400 万人」のように出す。
つまずきポイント 3 連
ここまでで一通り完成形だが、実装中に必ず踏むであろう罠を 3 つ。
罠 1: 半径を radius にする(scaleLinear で割り当て)
すでに書いたが本当に多い。.attr("r", (d) => d.value / 1000) のような直線比例で半径を決めると、見た目で値の 2 乗のスケールで誇張される。必ず d3.scaleSqrt を経由する。
| 入力 value | scaleLinear(r) | scaleSqrt(r) | 面積(scaleSqrt 採用時) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 4 | 4 | 2 | 4 |
| 16 | 16 | 4 | 16 |
| 100 | 100 | 10 | 100 |
scaleSqrt を使うと「面積が value に比例」する。これが正しい。
罠 2: 対数スケールで 0 値が爆死
d3.scaleLog() の domain に 0 や負数が混ざると log(0) = -Infinity で cy が NaN になり、バブルが「どこかへ消える」。市区町村別データや業種別の細分化で 0 件カテゴリが出るとよくやらかす。
対策:
- データ取得直後に
value > 0でフィルタリング - どうしても残すなら
Math.max(value, 1)で下限を 1 に - もしくは線形軸 + zoom UI に切り替える設計判断
罠 3: 統計年度の食い違い
商業統計は 5 年に 1 度の経済センサスで取るが、人口推計は毎年更新される。「人口は 2024 年、販売額は 2021 年」みたいなキメラデータでバブルチャートを作ると、東京の人口が伸びている分だけ「東京の 1 人当たり販売額が下がった」ように見える、というおかしな解釈になる。
/fetch-estat-data で取るときは Claude Code に「2021 年で全部揃えて」と明示するか、JSON の year フィールドを必ず保持してチャート凡例に「2021 年データ」と明示する。これは Part 8 の Bar Chart Race でも触れた話だが、時点を揃えるのは多変量可視化の生命線。
次回予告: Part 17 は Slope Graph
次回は 2 時点の比較に強い Slope Graph を扱う予定。「2015 年と 2020 年で都道府県別の高齢化率がどう動いたか」みたいな「順位の入れ替わり」を可視化する手法で、Edward Tufte が好んだスタイル。バブルチャートが「多軸の静止画」だとすれば、Slope Graph は「2 時点の動きを見せる」ためのデバイスで、対比として面白い。
連載全体(全 20 本)の中盤戦も終盤に差し掛かっている。残るは Slope Graph、Sankey、Treemap、Force-directed Graph あたり。多次元データを「静止画 1 枚」でどこまで語れるかの限界に挑戦する後半戦になる。