React で d3-force のネットワーク図を実戦投入するときに踏む3つの罠:ラベル衝突・再構築・モバイルズーム
ノードが数十を超えた瞬間にラベルが潰れ、選択するたびにグラフが飛び、スマホでは操作不能になる。d3-force のサンプルは綺麗に動くのに、実データを載せると別物になる。三つとも原因と直し方がはっきりしている。
d3-force のネットワーク図(ノードとエッジの関係グラフ)は、チュートリアルのデータだと気持ちよく動く。だがノードが数百、エッジが数百という実データを React に載せると、三つの壁に順番にぶつかる。ラベルが重なって読めない、ノードを選ぶたびに配置が飛ぶ、スマホで密なグラフが操作できない。どれもサンプルでは起きず、実戦投入で初めて出る。原因と直し方をまとめておく。
1. ラベルが重なって読めない:全表示でも全非表示でもなく「優先度つき間引き」
事象
ノードが数十を超えると、全ノードにラベルを常時描くと文字どうしが重なって潰れる。読めないなら、と全部消すと、今度は何のグラフか分からない。
原因
d3-force はノードの座標を決めるが、ラベルの配置までは面倒を見ない。密度が上がれば座標は近接するので、ラベルをそのまま描けば衝突するのは当然だ。間引くしかないが、ランダムに消すと重要なノードのラベルから消えてしまう。
解決策
優先度の高い順にラベルを並べ、すでに置いたラベルの矩形と当たらないものだけを描く(greedy な間引き)。残りは選択・ホバーしたときだけ出す。
優先度は「重要度 → 次数(つながりの多さ)」で決めると、ハブになっているノードのラベルが生き残る。
// ラベル候補を優先度順にソート(重要度 → 次数)
const candidates = nodes
.slice()
.sort((a, b) => (b.weight - a.weight) || (b.degree - a.degree));
// 文字幅は canvas の measureText で実測する(DOM に描く前に分かる)
const ctx = document.createElement("canvas").getContext("2d");
ctx.font = "12px sans-serif";
const placed = []; // 確定したラベルの矩形
const visibleLabels = [];
for (const node of candidates) {
const w = ctx.measureText(node.label).width;
const box = { x: node.x, y: node.y - 12, w, h: 14 };
// 既出ラベルのどれかと重なるなら、このラベルは描かない
if (placed.some((p) => overlaps(p, box))) continue;
placed.push(box);
visibleLabels.push(node.id);
}
// 矩形どうしの当たり判定(AABB)
function overlaps(a, b) {
return !(
a.x + a.w < b.x || b.x + b.w < a.x ||
a.y + a.h < b.y || b.y + b.h < a.y
);
}
visibleLabels に入った ID のラベルだけを常時表示し、それ以外は :hover や選択時に出す。これで「ハブは常に読める・全体は潰れない」が両立する。間引き計算はレイアウトが落ち着いたあと(simulation.on("end", …) や tick の間引き)に一度回せば十分だ。
2. ノードを選ぶたびにグラフ全体が再構築される
事象
ノードをクリックして関連をハイライトする実装を入れたら、選択するたびにシミュレーションが作り直され、配置が飛ぶ・カクつく。
原因
d3 の DOM 操作を React のレンダリングサイクルに巻き込んでいる。よくあるのは、グラフを構築する useEffect の依存配列に選択 state を入れてしまうパターンだ。
// ❌ 選択のたびにグラフ全体が作り直される
useEffect(() => {
const simulation = d3.forceSimulation(nodes) /* …構築… */;
// selected を参照しているので依存に入れざるを得ない、と思ってしまう
renderNodes(selected);
}, [nodes, edges, selected]); // ← selected が入ると毎回リセット
selected が変わるたびに effect 全体が再実行され、forceSimulation が新規生成されて alpha がリセットされる。だから配置が毎回飛ぶ。
解決策
「グラフを組む」effect と「選択をハイライトする」effect を分ける。 構築は [nodes, edges] だけに依存させ、選択は別 effect で属性・クラスを付け替えるだけにする。
// ✅ 構築は nodes/edges が変わったときだけ
useEffect(() => {
const simulation = d3.forceSimulation(nodes) /* …構築… */;
return () => simulation.stop();
}, [nodes, edges]);
// ✅ 選択ハイライトは別 effect。再構築せずクラスを付け替えるだけ
useEffect(() => {
d3.selectAll(".node")
.classed("is-highlighted", (d) => isNeighbor(selected, d));
}, [selected]);
イベントハンドラの中で「いま選択されている値」を参照したいだけなら、再構築を避けるために useRef に最新値を持たせて読む。state を直接 effect の依存に入れない。
const selectedRef = useRef(selected);
useEffect(() => { selectedRef.current = selected; }, [selected]);
// クリックハンドラ内では selectedRef.current を読む → 構築 effect は再実行されない
eslint の exhaustive-deps は「依存が足りない」と警告してくるが、ここは意図的なので // eslint-disable-next-line react-hooks/exhaustive-deps で明示する。d3 が DOM を直接触る以上、React の再レンダリングと d3 の再構築は切り離すのが基本方針になる。
3. スマホで密なグラフが操作できない:d3-zoom をドラッグと共存させる
事象
PC では問題ない密度のグラフが、スマホだと潰れて何も操作できない。ピンチで拡大しようとすると、ページごとスクロールしてしまう。
原因
ピンチズーム・パンを入れていない(または入れたらノードのドラッグとジェスチャが食い合う)。d3.zoom() をそのまま当てると、ノードを掴んで動かそうとした操作までズーム側が拾ってしまう。
解決策
d3.zoom() を <svg> に当て、中の <g>(コンテナ)を変形する。そのうえで .filter() でノード要素の上から始まったイベントを除外し、ドラッグとズームを分離する。
const g = svg.append("g"); // ここを transform で動かす
const zoom = d3.zoom()
.scaleExtent([0.2, 8])
.filter((event) => {
// ノードの上で始まったジェスチャはズームしない(ドラッグに渡す)
if (event.target.closest(".node")) return false;
return !event.button; // d3 既定のボタン除外は残す
})
.on("zoom", (event) => g.attr("transform", event.transform));
svg.call(zoom);
これでノードの上はドラッグ、それ以外の余白はピンチ/パンに振り分けられる。
ただし、これだけだとスマホのピンチでページがスクロールしてしまう。 ブラウザのデフォルトのタッチ挙動を切る必要がある。SVG に touch-action: none を当てる。
svg.graph {
touch-action: none; /* ブラウザのピンチ・スクロールを止め、d3 に渡す */
}
.filter() を上書きすると d3 既定のフィルタ(!event.ctrlKey && !event.button)も消えるので、残したい条件は自分で書き足す。ここではボタン判定だけ戻している。
まとめ
三つとも「d3-force の責務はノードの座標決めまで」という一点に行き着く。ラベル配置・React との同期・タッチ操作は自分で面倒を見る領域だ。サンプルが小さくて綺麗なのは、その領域がまだ問題にならない規模だから。実データを載せた瞬間に全部出てくる。
- ラベルは優先度順に矩形当たり判定で間引く
- 構築 effect と選択 effect を分け、選択は属性更新だけにする
- d3-zoom は
.filter()でドラッグと分離し、touch-action: noneでブラウザに渡さない
※ 本記事にはアフィリエイトリンクが含まれます。