D3.jsの棒グラフ、scale・axis・joinでつまずく人へ
Claude CodeでD3.jsの棒グラフを実装する手順を、scale・axis・data joinに絞って解説。コピペで動くVite+TS版と失敗談つき。
D3.jsで初めて棒グラフを書いたとき、僕の棒は画面の外に消えました。
数字を直接 y 座標に渡していたんです。CV数182を「上から182px」だと思っていた。D3では上が0で下が大きい、しかも値はそのままピクセルじゃない。scaleLinear を一枚かませて初めて、182が「下から伸びる棒」になる。ここを飛ばすと、コードは動くのに棒が見えない、という地獄に落ちます。
D3がReChartsやChart.jsと違うのは、<BarChart> みたいな完成品が無いこと。scale(数値を位置に変換する関数)、axis(目盛り)、join(データとSVG要素を結ぶ処理)を、自分で組み立てます。自由だけど、つなぎ目を一つ間違えると静かに壊れる。今日はそのつなぎ目だけを、Claude Codeに手伝わせながら通します。
この記事の要点
- D3の棒グラフは「データ → scale → SVG要素」の3段。値を直接ピクセルにしない。カテゴリは
scaleBand、数値はscaleLinearを必ず通す。 - 縦軸は上が0、下が最大。だから
scaleLinearのrangeは[height, 0]と逆に書く。ここが初心者の最初の壁。 - 更新で要素が増殖するのは
selection.join()を使っていないから。enter/update/exit を1行にまとめるのが今の標準。 - アニメーションは
transition()。棒を「下から伸ばす」には、初期y=height / height=0から目標値へ補間する。 - Claude Codeには「棒グラフ作って」ではなく、scale・axis・joinの責務とアクセシビリティ条件を渡すと修正回数が激減する。
- 関連の考え方・データ準備編とライブラリ選び編とは役割が違う。ここは純粋にD3の手の動かし方。
まず「値をそのまま描かない」を体に入れる
D3でいちばん最初に壊れるのは、たいてい座標計算です。原因はほぼ一つ。生のデータをそのままSVGの座標に渡しているから。
棒グラフでやりたいのは、こういう変換です。
- チャネル名
"Search"→ 横位置 130px(カテゴリ → 位置) - CV数
168→ 縦の長さ 240px(数値 → 長さ)
この「→」の部分がscaleです。手で計算してもいいけど、データが増えた瞬間に破綻します。だからD3では、カテゴリには scaleBand、連続した数値には scaleLinear を使う。これを通さずに attr("x", d.visitors) と書くと、レスポンシブにした途端ぜんぶズレます。
僕が部品ごとに「平たい日本語」と「今回の役割」を一度メモにしてから触ると、Claude Codeへの指示も急に具体的になりました。
| 部品 | 平たく言うと | 今回の棒グラフでの役割 |
|---|---|---|
| selection | DOM要素を選ぶ操作 | #chart に svg を足す |
| scaleBand | カテゴリ → 横位置+棒幅 | チャネル名を横に等間隔で並べる |
| scaleLinear | 数値 → 縦の長さ | CV数を棒の高さに変換する |
| axis | 目盛りとラベル | 横にチャネル、縦にCV数を出す |
| join | データとDOM要素の対応づけ | 件数が変わっても棒を作り直さない |
| transition | 変化をなめらかに | 棒を下から伸ばす |
縦軸が上下逆、という最大の罠
ここだけは声を大きくして言います。SVGの座標は上が0、下にいくほど数字が大きい。紙のグラフと逆です。
だから縦軸のscaleはこう書きます。
const y = d3
.scaleLinear()
.domain([0, d3.max(items, (d) => d.conversions) ?? 0]) // データの範囲:0〜最大CV数
.nice() // 軸の上端をキリのいい値に丸める
.range([height, 0]); // 画面の範囲:下端height → 上端0(ここが逆!)
range([height, 0]) の順番が肝です。値0を height(一番下)に、最大値を 0(一番上)に対応させる。だから棒の高さは height - y(d.conversions) で出す。僕は最初これを [0, height] と素直に書いて、棒が天井から生える絵を量産しました。
横軸の scaleBand はもっと素直です。カテゴリの配列を domain に渡し、range([0, width]) で横幅いっぱいに割り振る。棒1本の幅は x.bandwidth() が勝手に計算してくれます。padding(0.28) で棒と棒のすき間も決まる。手で割り算する必要はありません。
コピペで動くD3.js + TypeScriptサンプル
ここまでの話を1ファイルに落とします。npm create vite@latest d3-demo -- --template vanilla-ts で最小構成を作り、npm i d3 @types/d3 を入れて、下の4ファイルを置き換えれば npm run dev で動きます。D3は公式が案内するv7系前提です。
{
"scripts": {
"dev": "vite"
},
"dependencies": {
"d3": "^7.9.0"
},
"devDependencies": {
"@types/d3": "^7.4.3",
"typescript": "latest",
"vite": "latest"
}
}
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>D3 Conversion Chart</title>
</head>
<body>
<main class="page">
<h1>D3.js Conversion Dashboard</h1>
<section class="chart-shell" aria-describedby="chart-summary">
<div id="chart"></div>
<p id="chart-summary" class="sr-only"></p>
</section>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
:root {
color: #172033;
background: #f7f7f3;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
}
.page {
width: min(920px, calc(100vw - 32px));
margin: 40px auto;
}
.chart-shell {
border: 1px solid #d8d5cc;
border-radius: 8px;
background: #ffffff;
padding: 20px;
}
#chart {
min-height: 320px;
position: relative;
}
#chart svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.axis-label {
fill: #475569;
font-size: 12px;
}
.bar {
fill: #2563eb;
outline: none;
}
.bar:hover,
.bar:focus {
fill: #dc2626;
}
.trend-line {
fill: none;
stroke: #0f172a;
stroke-width: 2;
pointer-events: none;
}
.chart-tooltip {
position: absolute;
top: 0;
left: 0;
max-width: 220px;
border-radius: 6px;
background: #172033;
color: #ffffff;
font-size: 13px;
line-height: 1.5;
opacity: 0;
padding: 8px 10px;
pointer-events: none;
transform: translate(-9999px, -9999px);
transition: opacity 120ms ease;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
import * as d3 from "d3";
import "./style.css";
type ChannelDatum = {
channel: string;
visitors: number;
conversions: number;
};
const data: ChannelDatum[] = [
{ channel: "Search", visitors: 4200, conversions: 168 },
{ channel: "Newsletter", visitors: 2600, conversions: 182 },
{ channel: "Social", visitors: 3100, conversions: 96 },
{ channel: "Partner", visitors: 1400, conversions: 84 },
];
const numberFormat = new Intl.NumberFormat(undefined);
const percentFormat = new Intl.NumberFormat(undefined, {
style: "percent",
maximumFractionDigits: 1,
});
function conversionRate(datum: ChannelDatum): number {
return datum.visitors === 0 ? 0 : datum.conversions / datum.visitors;
}
function drawConversionChart(container: HTMLElement, items: ChannelDatum[]): void {
// 再描画のたびに前のSVGを消す(増殖防止の門番)
container.replaceChildren();
// 空データは早めに返す。0除算もここで避ける
if (items.length === 0) {
container.textContent = "No data to display.";
return;
}
const margin = { top: 28, right: 24, bottom: 56, left: 64 };
const outerWidth = 760;
const outerHeight = 420;
const width = outerWidth - margin.left - margin.right;
const height = outerHeight - margin.top - margin.bottom;
// viewBoxで描くとCSS側のwidth:100%だけでレスポンシブになる
const svg = d3
.select(container)
.append("svg")
.attr("viewBox", `0 0 ${outerWidth} ${outerHeight}`)
.attr("role", "img")
.attr("aria-labelledby", "chart-title chart-desc");
svg.append("title").attr("id", "chart-title").text("Conversions by channel");
svg
.append("desc")
.attr("id", "chart-desc")
.text("Bar chart comparing conversions from each acquisition channel.");
// marginの分だけ内側にずらした描画エリア
const plot = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// カテゴリ → 横位置+棒幅
const x = d3
.scaleBand<string>()
.domain(items.map((d) => d.channel))
.range([0, width])
.padding(0.28);
// 数値 → 縦の長さ。range([height, 0])で上下を反転させるのが肝
const y = d3
.scaleLinear()
.domain([0, d3.max(items, (d) => d.conversions) ?? 0])
.nice()
.range([height, 0]);
// 横軸:下端に置く
plot
.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x))
.call((axis) => axis.selectAll("text").attr("dy", "0.85em"));
// 縦軸:目盛りは5本くらいが読みやすい
plot.append("g").call(d3.axisLeft(y).ticks(5));
plot
.append("text")
.attr("class", "axis-label")
.attr("x", -margin.left + 4)
.attr("y", -10)
.text("Conversions");
const tooltip = d3.select(container).append("div").attr("class", "chart-tooltip");
function showTooltip(event: PointerEvent | FocusEvent, datum: ChannelDatum): void {
// マウス位置が取れるときはそこへ、キーボードfocus時は棒の中央へ
const xCenter = (x(datum.channel) ?? 0) + x.bandwidth() / 2 + margin.left;
const yTop = y(datum.conversions) + margin.top;
const [left, top] =
"clientX" in event ? d3.pointer(event, container) : [xCenter, yTop];
tooltip
.style("opacity", "1")
.style("transform", `translate(${left + 12}px, ${top - 28}px)`)
.html(
`<strong>${datum.channel}</strong><br />` +
`Visitors: ${numberFormat.format(datum.visitors)}<br />` +
`Conversions: ${numberFormat.format(datum.conversions)}<br />` +
`CVR: ${percentFormat.format(conversionRate(datum))}`,
);
}
function hideTooltip(): void {
tooltip.style("opacity", "0").style("transform", "translate(-9999px, -9999px)");
}
// データとrectを結ぶ。第2引数のキーで「どのデータがどの棒か」を固定する
const bars = plot
.selectAll<SVGRectElement, ChannelDatum>("rect.bar")
.data(items, (d) => d.channel)
.join((enter) =>
enter
.append("rect")
.attr("class", "bar")
.attr("x", (d) => x(d.channel) ?? 0)
.attr("width", x.bandwidth())
.attr("y", height) // アニメ開始位置:高さ0で下端に潰しておく
.attr("height", 0),
)
.attr("tabindex", 0)
.attr("role", "img")
.attr(
"aria-label",
(d) =>
`${d.channel}: ${numberFormat.format(d.conversions)} conversions, ${percentFormat.format(
conversionRate(d),
)} conversion rate`,
)
.on("pointerenter pointermove", showTooltip)
.on("focus", showTooltip)
.on("pointerleave blur", hideTooltip);
// 下から伸ばすアニメーション。indexで順番にずらすと気持ちいい
bars
.transition()
.duration(700)
.delay((_d, index) => index * 80)
.attr("x", (d) => x(d.channel) ?? 0)
.attr("width", x.bandwidth())
.attr("y", (d) => y(d.conversions))
.attr("height", (d) => height - y(d.conversions));
// 棒の頂点をつなぐ補助線
const trendLine = d3
.line<ChannelDatum>()
.x((d) => (x(d.channel) ?? 0) + x.bandwidth() / 2)
.y((d) => y(d.conversions))
.curve(d3.curveMonotoneX);
plot
.append("path")
.datum(items)
.attr("class", "trend-line")
.attr("d", trendLine);
}
const chart = document.querySelector<HTMLElement>("#chart");
if (!chart) {
throw new Error("Missing #chart element.");
}
drawConversionChart(chart, data);
const summary = document.querySelector<HTMLElement>("#chart-summary");
if (summary) {
const best = data.reduce((current, item) =>
conversionRate(item) > conversionRate(current) ? item : current,
);
summary.textContent = `Highest conversion rate: ${best.channel}, ${percentFormat.format(
conversionRate(best),
)}.`;
}
表示できたら、ブラウザのコンソールで最低限の確認をします。棒の本数、SVGの role、スクリーンリーダー用サマリーの3点を見れば、骨格が壊れていないかすぐ分かります。
// 棒は4本あるか
console.log(document.querySelectorAll("#chart rect.bar").length);
// SVGにrole="img"が付いているか
console.log(document.querySelector("#chart svg")?.getAttribute("role"));
// 隠しサマリーにテキストが入っているか
console.log(document.querySelector("#chart-summary")?.textContent);
D3の公式情報も手元に置いておくと安心です。全体像はD3 Getting started、データ結合はJoining data、scaleBand / scaleLinear はd3-scale、軸はd3-axis、アニメーションはd3-transitionを読むと、上のコードがなぜそう書いてあるか腑に落ちます。
data joinを使うと「棒の増殖」が止まる
D3に慣れていない人が必ず一度はやるのが、画面を更新するたびに棒や軸が増えていくバグです。フィルタを切り替えるたびに棒が重なって、3回押すと棒が12本に増える。あれです。
原因は2つあります。
- 前のSVGを消していない(
container.replaceChildren()を入れ忘れ) enterだけで描いて、データ更新時のupdate/exitを扱っていない
上のコードはどちらも対策済みです。冒頭で replaceChildren() を呼び、棒は selection.data(items, キー).join(...) で結んでいます。join は「新しく来たデータ=enter」「すでにあるデータ=update」「消えたデータ=exit」を1行で面倒みてくれる、D3 v6以降の標準パターン。.data() の第2引数にキー関数 (d) => d.channel を渡しているのも重要で、これが無いと「3番目の棒」が常に同じDOMに固定されて、並び替えで色や位置が入れ替わります。
リアルタイム更新やフィルタ付きダッシュボードを作るなら、最初から join で書く。あとから差し替えるのは想像以上に面倒です。
こういう場面でD3を選ぶ(3つ)
ReChartsやChart.jsで足りる場面でD3を持ち出すと、ただ大変なだけです。僕がD3を選ぶのは、標準の部品では表現が窮屈なときだけ。
| 場面 | D3が効く理由 | Claude Codeに渡すこと |
|---|---|---|
| 流入チャネル別のCV比較 | 棒+頂点をつなぐ補助線など、合成した図を自由に組める | アナリティクス実装のイベント定義を読ませ、指標名を軸ラベルに揃える |
| 時系列の異常値ハイライト | しきい値超えだけ色や注釈を変える、といった条件描画が得意 | パフォーマンス最適化と合わせ、点数が多いときの描画負荷を確認させる |
| A/Bテストの信頼区間つき比較 | 棒に誤差バーを重ねるような独自表現ができる | 集計ロジックとチャート描画を別関数に分け、scale の責務を固定させる |
逆に「とりあえず棒グラフ」「とりあえず円グラフ」なら、ライブラリのほうが速くて壊れにくい。その判断軸はライブラリ選び編に寄せたので、迷ったらそちらを先に読んでください。
僕がD3でやらかした失敗3つ
正直に書きます。最初のD3チャートは事故の見本市でした。
ひとつ目は、さっきの**range 上下逆問題**。scaleLinear().range([0, height]) と素直に書いて、棒が天井から生えました。D3では下が height、上が 0。range([height, 0]) と棒の高さ height - y(値) はセットで覚えるしかありません。
ふたつ目は、join を知らずに enter だけで描いたこと。月を切り替えるたびに古い棒が残り、12月を表示したら1月の棒も透けて見える、という珍現象が起きました。selection.data(...).join(...) に書き直した瞬間、嘘のように消えました。
みっつ目は、Tooltipをマウス専用にしたこと。mouseover だけで作ったら、キーボードで Tab 移動する人にも、スクリーンリーダーにも、棒の中身がまったく届いていませんでした。tabindex、aria-label、focus イベントを足して、ようやく「誰にでも読めるグラフ」になった。最初から条件に入れておけばよかった、と毎回思います。アクセシビリティの詰め方はアクセシビリティ対応にまとめています。
Claude Codeに頼むなら「scaleとjoinの責務」を渡す
D3の実装をClaude Codeに任せるとき、「D3で棒グラフ作って」だと毎回ビミョーな差分が返ってきます。D3は自由度が高いぶん、指示が曖昧だと判断が揺れるからです。
僕がいま投げているのは、こういう依頼文です。部品の責務とアクセシビリティ条件を、最初から全部書いておく。
Vite + TypeScript + D3 v7 で、流入チャネル別のCV数を表示するレスポンシブ棒グラフを作ってください。
- カテゴリは scaleBand、CV数は scaleLinear を通すこと(生の値を座標に直接渡さない)
- 縦軸は range([height, 0]) で反転させ、棒の高さは height - y(値) で出すこと
- 棒は selection.data(items, キー).join(...) で結び、再描画前に replaceChildren() すること
- ツールチップは focus と pointer の両方で出し、aria-label と tabindex を付けること
- 空配列のとき、0除算(visitors=0)のときの挙動も実装すること
- 最後に、確認すべきレビュー観点を箇条書きで返すこと
レビューを頼むときも「バグを探して」では弱い。「再描画時の重複、空配列、0除算、キーボード操作、スクリーンリーダー、モバイル幅、1000件時の描画負荷をレビューして」と観点を並べると、指摘の質が一段上がります。型に厳しくしたいならTypeScript Tipsの指示も足すと効きます。
よくある質問
Q. なぜ縦軸の range は [height, 0] と逆に書くんですか。
A. SVGは画面上端が y=0、下にいくほど値が増えるからです。値0をグラフの下端(height)、最大値を上端(0)に対応させたいので、range を反転させます。棒の高さも height - y(値) で出します。
Q. scaleBand と scaleLinear はどう使い分けますか。
A. scaleBand はカテゴリ(チャネル名、曜日など離散値)を等間隔の位置と棒幅に変換します。scaleLinear は連続した数値(CV数、金額など)を長さや位置に変換します。棒グラフは横が scaleBand、縦が scaleLinear が基本形です。
Q. データを更新すると棒が増えていきます。
A. 描画前に container.replaceChildren() で前のSVGを消し、棒は selection.data(items, キー).join(...) で結んでください。join が enter / update / exit を一括で扱うので、古い要素が残りません。キー関数を渡すと並び替えにも強くなります。
Q. ReactやAstroと一緒に使うと表示が崩れます。
A. D3が触るDOMをフレームワークが再レンダーで上書きするのが原因です。D3が操作する領域を1つのコンテナ(ref を渡した div など)に閉じ込め、フレームワーク側はその中を触らない、と役割を分けると衝突しません。
Q. Claude CodeにD3を任せて大丈夫ですか。 A. 部品の責務(scale・axis・joinの役割)とアクセシビリティ条件を明記すれば十分実用的です。逆に「いい感じに可視化して」だけだと、動くけれど保守しにくいコードになりがちです。指示の粒度がそのまま品質になります。
実際に試した結果
D3の難所を「データ型・scale・axis・mark・interaction」の5つに切り分けてClaude Codeに渡すようにしてから、修正の往復がはっきり減りました。いちばん効いたのは、range の反転とjoinのキー関数を依頼文に最初から書いておくこと。この2つは後から指摘しても直すのが面倒で、初回で固めておくとほぼ一発で通ります。
そしてもう一つ実感したのは、D3を「きれいなグラフを描く道具」だと思っているうちは元が取れない、ということ。CV、登録、継続みたいに収益に近い行動を軸に置いて初めて、そのグラフが意思決定に使われます。自分のサイトのデータでD3を一段ちゃんと使いこなしたくなったら、レビュー観点やCLAUDE.mdテンプレートをまとめた教材一覧を覗いてみてください。手を動かしながら自分の数字で試すのが、いちばん速く身につきます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。